mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6252ae094 | |||
| 198f45edee | |||
| 354a267830 | |||
| c33fe37798 | |||
| 667e05a0c9 | |||
| a9efae864d | |||
| cc668668ce | |||
| cfdd8a533b | |||
| 08cf526fe2 | |||
| 2bda050872 | |||
| 08d797235d | |||
| 97bcdb4d06 | |||
| 688f806682 | |||
| 43ddde4a5d | |||
| 08d7037176 | |||
| d5cb276965 | |||
| d69ebbee1b | |||
| 8c81bc60d0 | |||
| cc4955a8da | |||
| c87efa1f35 | |||
| 65a99608d9 | |||
| a9e4830ded | |||
| a3ff816176 | |||
| 06e517eb4e | |||
| 4d4b3a715f | |||
| 6bf86f1f1e | |||
| 516d80ae8d | |||
| 250d8a2c04 | |||
| e6e0a7ebb5 | |||
| cb8aa38cc9 | |||
| 14163d0965 | |||
| 3b8028a040 | |||
| c7f0139ad4 | |||
| f525704192 | |||
| db6ecaf446 | |||
| 59fabc16ac | |||
| aea16f2b5a | |||
| 93fd18eaad | |||
| b45cae1e48 | |||
| e12758ff91 | |||
| f0dda667bb | |||
| 611422e4df | |||
| 2cac9cad6a | |||
| 3d99ab4bb1 | |||
| e3938defd8 | |||
| f3751bd3ca | |||
| 7d7ef18f49 | |||
| f77da83c93 | |||
| 62847173f4 | |||
| df09fdbc32 | |||
| 87bb2f37f8 | |||
| 879aacacf8 | |||
| a9007d7b86 | |||
| 56a2ed1e4f | |||
| bf60758244 | |||
| d9613e29cb | |||
| 0a526c6f85 | |||
| 4fbf19f2f9 | |||
| 1fee62dd54 | |||
| ab72c5ad56 | |||
| df4c8a2994 | |||
| 417511367c | |||
| c3f94ab616 | |||
| 8eda982489 | |||
| cab698bf12 | |||
| c308a1ba7a | |||
| 874315db8e | |||
| 56b499e951 | |||
| 8c13914d6c | |||
| f0292b1606 | |||
| 732447d2dc | |||
| db0a2a007a | |||
| ed102b5393 | |||
| 970da30a99 | |||
| 113ded5fab | |||
| b4491ac605 | |||
| 53b092d287 | |||
| 07e263d7e9 | |||
| 25ea43e89f | |||
| c53aff7592 | |||
| 13aac4296d | |||
| 18d487a9bd | |||
| ff9653a353 | |||
| 400f200efa | |||
| 6dd202d277 | |||
| 16bf3ba593 | |||
| 8fa52c8e5e | |||
| 8601e38d4b | |||
| e171ff07ad | |||
| 6ae48ed70f |
@@ -21,6 +21,19 @@ SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliase
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
For Flatpak feed installation and updates, add Flathub for the required runtime before
|
||||
installing SolidCraft; keep both remotes in the same user installation:
|
||||
|
||||
```sh
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install --user solvecraft ai.storyteller.solvecraft
|
||||
flatpak run ai.storyteller.solvecraft
|
||||
```
|
||||
|
||||
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
|
||||
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
|
||||
@@ -122,9 +122,41 @@ jobs:
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Build the command line
|
||||
run: cargo build -p solvecraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
run: cargo xtask oracle --cli target/ci/solvecraft-cli
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: oracle-report
|
||||
path: docs/oracle.md
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, tests]
|
||||
needs: [changes, checks, clippy, tests, oracle]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -135,12 +167,14 @@ jobs:
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$TESTS" = success
|
||||
test "$ORACLE" = success
|
||||
fi
|
||||
|
||||
wasm:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD
|
||||
# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner;
|
||||
# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded
|
||||
# files, then commit them in a pull request if the change is intended.
|
||||
name: FreeCAD reference values
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cases:
|
||||
description: "Case directories (space separated); empty = every case whose source is FreeCAD"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate FreeCAD expected values
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install FreeCAD
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends freecad
|
||||
freecadcmd --version || true
|
||||
- name: Rebuild the cases
|
||||
env:
|
||||
CASES: ${{ inputs.cases }}
|
||||
run: |
|
||||
if [ -z "$CASES" ]; then
|
||||
CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ')
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
tools/freecad-oracle/run.sh --write $CASES
|
||||
- name: Show the changes
|
||||
run: git diff --stat && git diff -- tests/oracle/*/expected.json
|
||||
- name: Upload the regenerated files
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: freecad-expected
|
||||
path: |
|
||||
tests/oracle/*/expected.json
|
||||
tests/oracle/*/reference.stl
|
||||
@@ -37,6 +37,10 @@ jobs:
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Nightly release selection regression
|
||||
run: python3 packaging/tests/nightly_test.py
|
||||
- name: Windows executable icon validation regression
|
||||
run: python3 packaging/tests/windows_icon_test.py
|
||||
- name: Flatpak desktop smoke validation regression
|
||||
run: python3 packaging/tests/flatpak_test.py
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common xvfb xauth weston dbus-x11
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
@@ -17,9 +16,7 @@ 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/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
and the relevant `plan/architecture.md` section.
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
@@ -44,18 +41,22 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
|
||||
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
|
||||
artwork, help text, tooltips, templates or materials, and never commit files produced by
|
||||
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
|
||||
holds ids and names, nothing else). File formats come from public specifications. **Never read,
|
||||
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
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **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.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — 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 commercial CAD software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
@@ -72,10 +73,12 @@ People trust SolidCraft 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/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.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; 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`).
|
||||
|
||||
@@ -122,11 +125,12 @@ before the draft is verified.
|
||||
`ui.render` or `solvecraft-cli snapshot`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
|
||||
with Fusion's `measure.json` (writes `docs/oracle.md`).
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
|
||||
`cargo xtask oracle` recomputes `docs/oracle.md`.
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
|
||||
+31
-1
@@ -26,6 +26,27 @@ Generated-in-code assets are original and have no file to list:
|
||||
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solvecraft-web/fonts/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
@@ -55,5 +76,14 @@ Generated-in-code assets are original and have no file to list:
|
||||
|---|---|---|---|
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
|
||||
`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui
|
||||
(MIT OR Apache-2.0; both original licence texts are included). Rust sources and
|
||||
its original `egui.wgsl` shader are retained; no images or other artwork are copied.
|
||||
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
|
||||
and shares that effective count with the surface attachments and CAD callbacks.
|
||||
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
|
||||
sketches with a constraint solver, a parametric timeline of features, a B-rep
|
||||
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
|
||||
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
|
||||
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
|
||||
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
|
||||
kernel, and a desktop UI that agents can drive. Projects published by
|
||||
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
|
||||
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
|
||||
|
||||
## Product name and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
@@ -17,9 +16,7 @@ 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/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
and the relevant `plan/architecture.md` section.
|
||||
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
|
||||
is rejected for licence reasons).
|
||||
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
|
||||
@@ -44,18 +41,22 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
|
||||
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
|
||||
artwork, help text, tooltips, templates or materials, and never commit files produced by
|
||||
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
|
||||
holds ids and names, nothing else). File formats come from public specifications. **Never read,
|
||||
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
|
||||
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
|
||||
general CAD conventions and user feedback. Reference tests use analytic values and open-source
|
||||
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
|
||||
materials. File formats come from public specifications.
|
||||
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
|
||||
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
|
||||
measurements, command lists, ids or files produced with them.
|
||||
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
|
||||
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
|
||||
runs FreeCAD as a separate program through its public scripting API to compute reference values;
|
||||
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
|
||||
operations are our own.
|
||||
- **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.
|
||||
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
|
||||
artwork from commercial CAD products — 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 commercial CAD software are never committed.
|
||||
Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
|
||||
@@ -72,10 +73,12 @@ People trust SolidCraft 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/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.
|
||||
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
|
||||
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
|
||||
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
|
||||
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
|
||||
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
|
||||
generated reference data; 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`).
|
||||
|
||||
@@ -99,11 +102,12 @@ before the draft is verified.
|
||||
`ui.render` or `solvecraft-cli snapshot`.
|
||||
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
|
||||
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
|
||||
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
|
||||
with Fusion's `measure.json` (writes `docs/oracle.md`).
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
|
||||
`cargo xtask oracle` recomputes `docs/oracle.md`.
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
|
||||
+44
-37
@@ -16,12 +16,19 @@ You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
|
||||
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solvecraft
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo xtask test # unit, engine and UI-scenario tests
|
||||
cargo xtask ci # everything a PR must pass (see below)
|
||||
```
|
||||
|
||||
SolidCraft is the product and repository name. Cargo packages and executables retain
|
||||
their `solvecraft` names for compatibility with existing scripts and MCP clients.
|
||||
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
|
||||
`cargo build --release -p solvecraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
|
||||
after building). There is no Cargo package named `solidcraft`.
|
||||
|
||||
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
|
||||
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
|
||||
HTTPS or localhost, because it uses the Origin Private File System.
|
||||
@@ -41,32 +48,21 @@ ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to creat
|
||||
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
|
||||
```
|
||||
|
||||
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) puts the Wayland, X11,
|
||||
EGL-loader and D-Bus libraries on `LD_LIBRARY_PATH`, so `cargo run --release -p solvecraft` works
|
||||
from that shell as on any other distribution, file dialogs included. `nix run` builds the app and
|
||||
wraps the binary the same way. The Mesa drivers stay the system's, from `/run/opengl-driver`
|
||||
(`hardware.graphics.enable`): that is the driver stack the compositor runs on, and wgpu reaches the
|
||||
integrated GPU through the EGL loader. The shell deliberately brings no Rust toolchain — the one on
|
||||
`PATH` keeps its build cache, where a second `rustc` would make every build recompile the workspace
|
||||
from scratch; `nix develop .#rust` is there for a machine with no Rust at all.
|
||||
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) supplies Wayland,
|
||||
X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS
|
||||
driver manifests in `/run/opengl-driver/share`, so Vulkan can discover the system
|
||||
drivers. Enable `hardware.graphics.enable` on NixOS; other Linux distributions
|
||||
may need a matching [nixGL wrapper](https://github.com/nix-community/nixGL).
|
||||
The default development shell retains the Rust/C toolchain already on `PATH`;
|
||||
`nix develop .#rust` supplies the pinned toolchain and builds in `target/nix`.
|
||||
|
||||
**Anti-aliasing on the OpenGL backend.** NixOS keeps the Vulkan ICD descriptions
|
||||
(`/run/opengl-driver/share/vulkan/icd.d`) off the Vulkan loader's search path, so wgpu picks its
|
||||
OpenGL backend on the integrated GPU. On that backend any multisampling above 1× — the default is
|
||||
4× — draws a black window: the UI still lays out and the control channel still answers, but every
|
||||
frame, `ui.screenshot` included, comes out empty. Set **Preferences ▸ Graphics ▸ Anti-aliasing** to
|
||||
1× and restart (the setting applies on the next start), or write it before the first run, since a
|
||||
black window is no way to reach the dialog:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.local/share/solvecraft
|
||||
echo '{"msaa": 1}' > ~/.local/share/solvecraft/graphics.json
|
||||
```
|
||||
|
||||
`apps/solvecraft/src/main.rs` reads that file (`graphics_path()`, eframe's storage dir) before the
|
||||
window opens; 2× and 4× are the same black window. The Vulkan backend is no way out of it either:
|
||||
`libvulkan.so.1` is not on `LD_LIBRARY_PATH`, so `WGPU_BACKEND=vulkan` exits with a
|
||||
`CreateSurfaceError` instead.
|
||||
To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal
|
||||
commands, desktop entry and generated icons, use `nix profile install .#solidcraft`.
|
||||
`nix build` alone creates a build result; it does not install a launcher in your
|
||||
desktop menu. The installed `solidcraft` command and compatible `solvecraft`
|
||||
command run the same wrapped app; `solvecraft-cli` is included as well. See
|
||||
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
|
||||
desktop integration and how to distinguish an old executable on `PATH`.
|
||||
|
||||
## How the code is laid out
|
||||
|
||||
@@ -92,13 +88,16 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
|
||||
## Rules that aren't negotiable
|
||||
|
||||
1. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
|
||||
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
|
||||
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
|
||||
our own (`solid.fillet`, not Fusion's internal names).
|
||||
1. **Independent.** SolidCraft is independent. Behaviour is designed from public
|
||||
documentation, general CAD conventions and user feedback. Reference tests use analytic values
|
||||
and open-source tools. We don't drive, script, automate or extract data from commercial CAD
|
||||
products, and we never copy another product's code, icons, artwork, help text, tooltips,
|
||||
templates or materials. Command ids are our own (`solid.fillet`).
|
||||
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
|
||||
(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
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
|
||||
open-source application to get reference values for a test is fine; reading its source is
|
||||
not.) 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).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
@@ -123,7 +122,7 @@ the most complete description of the house style.
|
||||
|---|---|---|
|
||||
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
|
||||
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
|
||||
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
|
||||
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
|
||||
|
||||
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
|
||||
press keys, then assert on the result.
|
||||
@@ -142,6 +141,10 @@ press keys, then assert on the result.
|
||||
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
|
||||
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
|
||||
|
||||
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
|
||||
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
|
||||
(it can then merge right away), and remove that line in the PR that fixes the bug.
|
||||
|
||||
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
|
||||
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
|
||||
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
|
||||
@@ -150,11 +153,15 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
|
||||
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
|
||||
depend on wall-clock timing.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
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.
|
||||
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
|
||||
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
|
||||
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
|
||||
the surface itself). Expected values are either worked out in closed form
|
||||
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
|
||||
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
|
||||
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
|
||||
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
|
||||
change an expected value to match SolidCraft's output.
|
||||
|
||||
## Sending a change
|
||||
|
||||
|
||||
Generated
+94
-20
@@ -971,8 +971,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1041,6 +1039,20 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -3106,6 +3118,15 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -3269,12 +3290,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3284,7 +3307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3293,7 +3316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3308,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3324,7 +3347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3332,7 +3355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3344,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3359,7 +3382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3367,7 +3390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3377,7 +3400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3388,7 +3411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3403,7 +3426,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3589,10 +3612,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3611,13 +3649,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
@@ -3913,6 +3957,26 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"vswhom-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom-sys"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4698,6 +4762,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
@@ -4775,7 +4849,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+4
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -100,3 +100,6 @@ truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
matext4cgmath = { path = "vendor/matext4cgmath" }
|
||||
|
||||
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
|
||||
@@ -19,8 +19,9 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
|
||||
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
|
||||
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
|
||||
|
||||
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
|
||||
list is in ATTRIBUTION.md.
|
||||
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
|
||||
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
|
||||
per-file list is in ATTRIBUTION.md.
|
||||
|
||||
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
|
||||
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
|
||||
model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||
|
||||
@@ -14,10 +14,14 @@ model through the built-in MCP server. Part of the storytold "craft" family.
|
||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
|
||||
|
||||
**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).
|
||||
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
|
||||
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
**Status: early but usable for simple parts.** 75 of 76 public reference parts
|
||||
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
|
||||
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
|
||||
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
|
||||
are listed by area in [docs/features.md](docs/features.md); status and plans are in [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).
|
||||
|
||||
@@ -74,7 +78,8 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
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;
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies);
|
||||
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
@@ -163,6 +168,7 @@ 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 independent. Behaviour is designed from public documentation, general CAD
|
||||
conventions and user feedback. Reference tests use analytic values and open-source tools.
|
||||
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
|
||||
Fusion are trademarks of Autodesk, Inc.
|
||||
|
||||
+101
-17
@@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | **75 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR; 75-sm-bracket-holes fails (#296). |
|
||||
| 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) |
|
||||
@@ -20,8 +20,7 @@ The issue batch adds preservation of blind pockets in through cuts, associative
|
||||
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
|
||||
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
@@ -38,8 +37,7 @@ In scope, in priority order:
|
||||
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
|
||||
|
||||
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
|
||||
headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## Milestones
|
||||
|
||||
@@ -49,13 +47,13 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
| 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); 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 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), 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. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | 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; 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: 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 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
| 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 |
|
||||
@@ -63,10 +61,33 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
|
||||
arguments reject such a name when an older design's construction plane already has it. Use
|
||||
the other alias, the construction feature's numeric ID or an explicit plane; existing
|
||||
stored references keep their saved meaning. Section Analysis uses the same check.
|
||||
- Sketch text can be edited from a selected outline's context menu or the Edit Text command.
|
||||
Content, baseline X/Y, height and rotation update the original text source in one undo step;
|
||||
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
|
||||
use the document units captured when editing starts; generated outlines remain associated
|
||||
with their text source, and untouched inputs preserve their saved internal values.
|
||||
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
|
||||
waits for a distinct point without a degenerate-shape error or history entry. Typed input
|
||||
and programmatic commands retain their validation.
|
||||
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
|
||||
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
|
||||
associative axis expressions and construction-axis references remain unsupported.
|
||||
- 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.
|
||||
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
|
||||
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
|
||||
the current design unit; changing units leaves existing geometry and expressions intact.
|
||||
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
|
||||
and pocket depths, interference volumes and parts-library lengths also use design units.
|
||||
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
|
||||
consumes complete point/curve selections consistently; mixed or ambiguous selections
|
||||
retain the picking workflow. Rejected constraints preserve selection and undo history.
|
||||
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
|
||||
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
|
||||
useful reproduction context. The draft is editable, with optional capture of the actual
|
||||
@@ -141,7 +162,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
|
||||
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
|
||||
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
|
||||
edges.
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs).
|
||||
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
|
||||
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;
|
||||
@@ -155,16 +177,15 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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,
|
||||
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
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.
|
||||
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
|
||||
references resolve by name first, follow splits and merges, and warn when the entity split,
|
||||
merged or vanished, or was found by geometry only (operations that report no history 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
|
||||
@@ -231,6 +252,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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.
|
||||
- Project accepts complete supported preselection in one undo step. Visible external sketch
|
||||
curves and points retain their source sketch identity; model geometry, axes and planes use
|
||||
the existing linked projection commands. Rejection preserves the document and selection;
|
||||
mixed selections retain ordinary tool-first picking.
|
||||
- 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.
|
||||
@@ -253,6 +278,18 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
|
||||
Parameters opens the user/model variable editor; Configurations opens the configuration table.
|
||||
Configure remains available in the panel dropdown.
|
||||
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
|
||||
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
|
||||
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
|
||||
- Movable window title bars follow the app's dark/light header palette. Radial right-click
|
||||
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
|
||||
- Desktop distribution: running Windows windows and executable resources use the app icon;
|
||||
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
|
||||
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
|
||||
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
|
||||
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
|
||||
callbacks to prevent black frames. Other backends retain the requested sample count, and
|
||||
stored preferences remain unchanged.
|
||||
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
|
||||
restrict values. Preview explains effective values without modifying the document; applying
|
||||
rules validates rows atomically. Length expressions retain their authoring units across
|
||||
@@ -271,13 +308,60 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
- 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.
|
||||
check. 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).
|
||||
|
||||
## 3D sketch bridges
|
||||
|
||||
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
|
||||
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
|
||||
construction-plane selection of 3D wires and loft guide rails remain separate work.
|
||||
Feature dialog header chevrons collapse and restore the form without discarding selections
|
||||
or edited values, including the Appearance panel.
|
||||
## Construction on 3D paths
|
||||
|
||||
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
|
||||
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
|
||||
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
|
||||
and reference-object placement remain separate work, as do loft rails and centerlines.
|
||||
|
||||
## Document-opening performance
|
||||
|
||||
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
|
||||
to eight workers, retaining serial traversal, result order and persistent source identities.
|
||||
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
|
||||
import times; single-solid assembly parts are not scheduled across nodes. See
|
||||
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
|
||||
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
|
||||
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
|
||||
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
|
||||
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
|
||||
on a local case assembly; shape measurements and topology match the original build.
|
||||
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
|
||||
- Prepared assembly geometry survives selection and view changes while real geometry and
|
||||
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
|
||||
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
|
||||
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
|
||||
|
||||
## Kernel
|
||||
|
||||
- Successful Boolean retries report their strategy and numerical adjustments as
|
||||
feature warnings and structured inspection data. Diagnostics survive cached
|
||||
evaluation, undo/redo and cold reopening; the robustness runner counts retry
|
||||
events by strategy and reports any bounded-log truncation. Existing geometry
|
||||
acceptance checks remain in force.
|
||||
|
||||
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
|
||||
polylines without panicking. Direct constructor regressions retain valid open endpoints
|
||||
and closed spherical intersections; the original interactive trigger remains unidentified.
|
||||
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
|
||||
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
|
||||
support and shared topology, preserving real steps and intentional boundaries. The local
|
||||
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
|
||||
cover stepped stock, STEP round trips and saved projections from removed pieces.
|
||||
|
||||
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
|
||||
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
|
||||
|
||||
+352
-8
@@ -328,9 +328,9 @@ thiserror-impl 2.0.21 (MIT OR Apache-2.0)
|
||||
tiff 0.11.3 (MIT)
|
||||
tinystr 0.8.4 (Unicode-3.0)
|
||||
tokio 1.53.2 (MIT)
|
||||
toml_datetime 1.1.1+spec-1.1.0 (MIT OR Apache-2.0)
|
||||
toml_datetime 1.1.2+spec-1.1.0 (MIT OR Apache-2.0)
|
||||
toml_edit 0.25.15+spec-1.1.0 (MIT OR Apache-2.0)
|
||||
toml_parser 1.1.3+spec-1.1.0 (MIT OR Apache-2.0)
|
||||
toml_parser 1.1.5+spec-1.1.0 (MIT OR Apache-2.0)
|
||||
tracing 0.1.44 (MIT)
|
||||
tracing-attributes 0.1.31 (MIT)
|
||||
tracing-core 0.1.36 (MIT)
|
||||
@@ -2230,7 +2230,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, presser 0.3.1
|
||||
bit-set 0.10.0, bit-vec 0.9.1, downcast-rs 1.2.1, egui-wgpu 0.36.2, presser 0.3.1
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -3417,9 +3417,8 @@ SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
crc32fast 1.5.2, dpi 0.1.2, foreign-types 0.5.0, foreign-types-macros 0.2.4, foreign-types-shared
|
||||
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_datetime 1.1.1+spec-1.1.0,
|
||||
toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0, unicode-general-category 1.1.0, winit
|
||||
0.30.13
|
||||
0.3.1, hex 0.4.3, jni-sys 0.3.1, jni-sys 0.4.1, quick-error 2.0.1, toml_edit 0.25.15+spec-1.1.0,
|
||||
unicode-general-category 1.1.0, winit 0.30.13
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
@@ -4226,6 +4225,36 @@ and labelled as such in comments in the individual source files. All
|
||||
have been licensed under extremely permissive terms.
|
||||
------------------------------------------------------------------------------
|
||||
|
||||
================================================================================
|
||||
egui-wgpu 0.36.2
|
||||
================================================================================
|
||||
|
||||
Copyright (c) 2018-2021 Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
either 1.19.0, itertools 0.13.0, itertools 0.15.0
|
||||
================================================================================
|
||||
@@ -10207,7 +10236,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
toml_datetime 1.1.1+spec-1.1.0, toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.3+spec-1.1.0
|
||||
toml_datetime 1.1.2+spec-1.1.0, toml_edit 0.25.15+spec-1.1.0, toml_parser 1.1.5+spec-1.1.0
|
||||
================================================================================
|
||||
|
||||
Copyright (c) Individual contributors
|
||||
@@ -10230,6 +10259,212 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
================================================================================
|
||||
toml_datetime 1.1.2+spec-1.1.0, toml_parser 1.1.5+spec-1.1.0
|
||||
================================================================================
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright Individual contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
================================================================================
|
||||
tracing 0.1.44, tracing-attributes 0.1.31, tracing-core 0.1.36
|
||||
================================================================================
|
||||
@@ -13061,7 +13296,6 @@ dispatch2 0.3.1: Zlib OR Apache-2.0 OR MIT (https://github.com/madsmtm/objc2)
|
||||
ecolor 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
|
||||
eframe 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/eframe)
|
||||
egui 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui)
|
||||
egui-wgpu 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-wgpu)
|
||||
egui-winit 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui-winit)
|
||||
egui_glow 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/egui_glow)
|
||||
emath 0.36.2: MIT OR Apache-2.0 (https://github.com/emilk/egui/tree/main/crates/emath)
|
||||
@@ -13111,3 +13345,113 @@ truck-geotrait 0.4.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-modeling 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-polymesh 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-topology 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
|
||||
================================================================================
|
||||
Bundled fonts (SIL Open Font License, Version 1.1)
|
||||
================================================================================
|
||||
|
||||
- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)
|
||||
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
|
||||
Source: https://github.com/liberationfonts
|
||||
|
||||
- BIZUDPGothic-Regular.ttf (apps/solvecraft-web/fonts/BIZUDPGothic-Regular.ttf)
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
|
||||
|
||||
================================================================================
|
||||
SIL OPEN FONT LICENSE Version 1.1
|
||||
================================================================================
|
||||
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Original SolidCraft mark, shared by desktop, packaging and CLI.
|
||||
/// This block is the icon image data
|
||||
pub fn rgba(size: usize) -> Result<Vec<u8>, String> {
|
||||
if !(1..=2048).contains(&size) {
|
||||
return Err("icon size must be 1..=2048".into());
|
||||
}
|
||||
type P = (f32, f32);
|
||||
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
|
||||
let ss = if size > 512 { 2 } else { 4 };
|
||||
let n = size * ss;
|
||||
let mut buf = vec![[0u8; 4]; n * n];
|
||||
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
|
||||
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
|
||||
// Fill a convex polygon given in SVG units.
|
||||
let mut fill = |pts: &[P], col: [u8; 3]| {
|
||||
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
|
||||
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
|
||||
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
let cy = y as f32 + 0.5;
|
||||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||
for i in 0..v.len() {
|
||||
let (a, b) = (v[i], v[(i + 1) % v.len()]);
|
||||
if (a.1 <= cy) != (b.1 <= cy) {
|
||||
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
continue;
|
||||
}
|
||||
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
|
||||
}
|
||||
}
|
||||
};
|
||||
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
|
||||
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
|
||||
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
|
||||
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
|
||||
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
|
||||
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
|
||||
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
|
||||
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
|
||||
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
|
||||
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
|
||||
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
|
||||
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
|
||||
let white = [255, 255, 255];
|
||||
let mut stroke = |pts: &[P], closed: bool| {
|
||||
let m = pts.len();
|
||||
for i in 0..(if closed { m } else { m - 1 }) {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
||||
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
||||
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
|
||||
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
|
||||
}
|
||||
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
|
||||
let disc: Vec<P> = (0..16)
|
||||
.map(|k| {
|
||||
let t = k as f32 * std::f32::consts::TAU / 16.0;
|
||||
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
|
||||
})
|
||||
.collect();
|
||||
fill(&disc, white);
|
||||
}
|
||||
};
|
||||
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
|
||||
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
|
||||
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
|
||||
stroke(
|
||||
&[
|
||||
(178.5, 5.8),
|
||||
(5.0, 105.9),
|
||||
(5.0, 206.1),
|
||||
(91.7, 256.2),
|
||||
(91.7, 356.4),
|
||||
(178.5, 406.4),
|
||||
(352.0, 306.3),
|
||||
(352.0, 206.1),
|
||||
(265.2, 156.0),
|
||||
(265.2, 55.9),
|
||||
],
|
||||
true,
|
||||
);
|
||||
// Average each ss x ss block down to one pixel (straight alpha = covered share).
|
||||
let mut rgba = Vec::with_capacity(size * size * 4);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
|
||||
for j in 0..ss {
|
||||
for i in 0..ss {
|
||||
let p = buf[(y * ss + j) * n + x * ss + i];
|
||||
if p[3] != 0 {
|
||||
cnt += 1;
|
||||
for k in 0..3 {
|
||||
sum[k] += u32::from(p[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cnt == 0 {
|
||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
let a = 255 * cnt / (ss * ss) as u32;
|
||||
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn shared_mark_has_transparency_and_original_colors_at_window_and_package_sizes() {
|
||||
for size in [16, 24, 32, 48, 64, 128, 256] {
|
||||
let pixels = super::rgba(size).expect("valid size");
|
||||
assert_eq!(pixels.len(), size * size * 4);
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 0));
|
||||
assert!(pixels.as_chunks::<4>().0.iter().any(|p| p[3] == 255 && p[2] > p[0]));
|
||||
}
|
||||
assert!(super::rgba(0).is_err());
|
||||
assert!(super::rgba(2049).is_err());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ pub fn one(args: &[String]) -> Result<(), String> {
|
||||
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
|
||||
"unsupported": run.unsupported,
|
||||
"rejected": run.rejected,
|
||||
"boolean_fallbacks": run.boolean_fallbacks,
|
||||
"boolean_reports_truncated": run.boolean_reports_truncated,
|
||||
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
|
||||
});
|
||||
if run.outcome.is_bug() {
|
||||
@@ -179,9 +181,23 @@ fn summarise(results: &[Value], took: Duration) -> String {
|
||||
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
|
||||
let mut fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
|
||||
let mut truncated = [0usize; 2];
|
||||
let mut steps = 0;
|
||||
let (mut dp, mut bp) = (0, 0);
|
||||
for v in results {
|
||||
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
|
||||
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
|
||||
truncated[i] += 1;
|
||||
}
|
||||
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
|
||||
for (strategy, count) in counts {
|
||||
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
|
||||
*total = total.saturating_add(count.as_u64().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
|
||||
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
|
||||
let k = if v.get("timeout").is_some() {
|
||||
@@ -220,5 +236,28 @@ fn summarise(results: &[Value], took: Duration) -> String {
|
||||
for (k, c) in &boolean {
|
||||
s += &format!("- {k}: {}\n", pct(*c));
|
||||
}
|
||||
s += "\nSuccessful Boolean retries (events, including nested operations):\n";
|
||||
for (strategy, [designs, checks]) in &fallbacks {
|
||||
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
|
||||
}
|
||||
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
|
||||
let results = [
|
||||
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
|
||||
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
|
||||
json!({"timeout":90}),
|
||||
];
|
||||
let text = summarise(&results, Duration::ZERO);
|
||||
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
|
||||
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
|
||||
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-112
@@ -8,8 +8,7 @@
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
|
||||
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
@@ -20,11 +19,14 @@
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
|
||||
@@ -39,7 +41,6 @@ const USAGE: &str = "usage:
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
@@ -70,7 +71,6 @@ fn main() -> ExitCode {
|
||||
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
|
||||
}),
|
||||
Some("oracle") => oracle::run(&args[1..]),
|
||||
Some("step-corpus") => oracle::step_corpus(&args[1..]),
|
||||
Some("fuzz") => fuzz::run(&args[1..]),
|
||||
Some("fuzz-one") => fuzz::one(&args[1..]),
|
||||
Some("mcp") => cmd_mcp(&args[1..]),
|
||||
@@ -245,113 +245,8 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This block is the icon image data
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
use solvecraft_engine::render::png;
|
||||
type P = (f32, f32);
|
||||
// Coverage buffer at 4x4 samples per pixel (2x2 for big sizes), averaged down at the end.
|
||||
let ss = if size > 512 { 2 } else { 4 };
|
||||
let n = size * ss;
|
||||
let mut buf = vec![[0u8; 4]; n * n];
|
||||
let s = n as f32 / 412.217; // SVG units -> samples: the logo fills the height
|
||||
let ox = (n as f32 - 357.0 * s) / 2.0; // and is centred sideways
|
||||
// Fill a convex polygon given in SVG units.
|
||||
let mut fill = |pts: &[P], col: [u8; 3]| {
|
||||
let v: Vec<P> = pts.iter().map(|p| (ox + p.0 * s, p.1 * s)).collect();
|
||||
let (top, bottom) = v.iter().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p.1), b.max(p.1)));
|
||||
for y in ((top - 0.5).ceil() as i32).max(0)..=((bottom - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
let cy = y as f32 + 0.5;
|
||||
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
||||
for i in 0..v.len() {
|
||||
let (a, b) = (v[i], v[(i + 1) % v.len()]);
|
||||
if (a.1 <= cy) != (b.1 <= cy) {
|
||||
let x = a.0 + (cy - a.1) / (b.1 - a.1) * (b.0 - a.0);
|
||||
lo = lo.min(x);
|
||||
hi = hi.max(x);
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
continue;
|
||||
}
|
||||
for x in ((lo - 0.5).ceil() as i32).max(0)..=((hi - 0.5).floor() as i32).min(n as i32 - 1) {
|
||||
buf[y as usize * n + x as usize] = [col[0], col[1], col[2], 255];
|
||||
}
|
||||
}
|
||||
};
|
||||
// Faces: each is a few overlapping convex pieces (the filler only does convex shapes).
|
||||
let (light, mid, dark) = ([96, 158, 236], [58, 136, 238], [55, 108, 197]);
|
||||
fill(&[(3.5, 105.9), (177.0, 5.8), (263.4, 55.6), (263.8, 156.0), (177.0, 406.4)], light);
|
||||
fill(&[(177.0, 206.1), (263.8, 156.0), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], light);
|
||||
fill(&[(3.5, 105.9), (177.0, 206.1), (177.0, 406.4)], mid);
|
||||
fill(&[(3.5, 105.9), (3.5, 205.7), (90.3, 256.2), (177.0, 206.1)], mid);
|
||||
fill(&[(177.0, 206.1), (90.3, 256.2), (90.0, 356.2), (177.0, 406.4)], mid);
|
||||
fill(&[(264.1, 155.8), (177.0, 206.1), (177.0, 406.4), (263.8, 256.2)], dark);
|
||||
fill(&[(263.8, 256.2), (350.5, 206.2), (350.5, 306.3), (177.0, 406.4)], dark);
|
||||
fill(&[(177.0, 206.1), (177.0, 406.4), (350.5, 306.3)], dark);
|
||||
fill(&[(263.8, 55.9), (263.8, 156.0), (177.1, 105.9)], dark);
|
||||
// White edge lines, 10 units wide: a rectangle per segment and a disc at each inner corner.
|
||||
let white = [255, 255, 255];
|
||||
let mut stroke = |pts: &[P], closed: bool| {
|
||||
let m = pts.len();
|
||||
for i in 0..(if closed { m } else { m - 1 }) {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % m]);
|
||||
let l = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
|
||||
let (nx, ny) = (-(b.1 - a.1) / l * 5.0, (b.0 - a.0) / l * 5.0);
|
||||
fill(&[(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)], white);
|
||||
}
|
||||
for c in &pts[if closed { 0 } else { 1 }..if closed { m } else { m - 1 }] {
|
||||
let disc: Vec<P> = (0..16)
|
||||
.map(|k| {
|
||||
let t = k as f32 * std::f32::consts::TAU / 16.0;
|
||||
(c.0 + 5.0 * t.cos(), c.1 + 5.0 * t.sin())
|
||||
})
|
||||
.collect();
|
||||
fill(&disc, white);
|
||||
}
|
||||
};
|
||||
stroke(&[(263.8, 55.9), (177.1, 105.9), (263.8, 156.0), (263.8, 256.2), (350.6, 206.1)], false);
|
||||
stroke(&[(5.0, 105.9), (178.5, 206.1), (178.5, 406.4)], false);
|
||||
stroke(&[(177.0, 206.1), (263.8, 156.0)], false);
|
||||
stroke(
|
||||
&[
|
||||
(178.5, 5.8),
|
||||
(5.0, 105.9),
|
||||
(5.0, 206.1),
|
||||
(91.7, 256.2),
|
||||
(91.7, 356.4),
|
||||
(178.5, 406.4),
|
||||
(352.0, 306.3),
|
||||
(352.0, 206.1),
|
||||
(265.2, 156.0),
|
||||
(265.2, 55.9),
|
||||
],
|
||||
true,
|
||||
);
|
||||
// Average each ss x ss block down to one pixel (straight alpha = covered share).
|
||||
let mut rgba = Vec::with_capacity(size * size * 4);
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
let (mut sum, mut cnt) = ([0u32; 3], 0u32);
|
||||
for j in 0..ss {
|
||||
for i in 0..ss {
|
||||
let p = buf[(y * ss + j) * n + x * ss + i];
|
||||
if p[3] != 0 {
|
||||
cnt += 1;
|
||||
for k in 0..3 {
|
||||
sum[k] += u32::from(p[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cnt == 0 {
|
||||
rgba.extend_from_slice(&[0, 0, 0, 0]);
|
||||
} else {
|
||||
let a = 255 * cnt / (ss * ss) as u32;
|
||||
rgba.extend_from_slice(&[(sum[0] / cnt) as u8, (sum[1] / cnt) as u8, (sum[2] / cnt) as u8, a as u8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
png(size, size, rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
|
||||
}
|
||||
|
||||
/// A Windows icon file holding PNG images (Vista and later read these).
|
||||
@@ -391,7 +286,13 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
|
||||
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
|
||||
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
|
||||
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| {
|
||||
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
|
||||
format!(
|
||||
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
|
||||
`solvecraft --control {port}` (or SOLVECRAFT_CONTROL_PORT={port}) and retry"
|
||||
)
|
||||
})?),
|
||||
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
|
||||
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
|
||||
};
|
||||
|
||||
+153
-167
@@ -1,129 +1,149 @@
|
||||
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
|
||||
//! compare them with the same measurements.
|
||||
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
|
||||
//! compare the part with `expected.json` (closed-form values, or values from an independent
|
||||
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
|
||||
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
|
||||
use crate::{read_json, recipe, seams};
|
||||
use crate::spatial::{self, Tri};
|
||||
use crate::{read_json, recipe};
|
||||
|
||||
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
|
||||
pub const REL_TOL: f64 = 1e-3;
|
||||
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
|
||||
const ABS_FRAC: f64 = 1e-3;
|
||||
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
|
||||
const MESH_TOL: f64 = 0.01;
|
||||
/// Relative tolerance on the principal second moments of the two triangulations.
|
||||
const INERTIA_REL: f64 = 2e-3;
|
||||
|
||||
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
|
||||
const APPROXIMATE: [(&str, f64, &str); 1] = [(
|
||||
"49-helical-sweep",
|
||||
5e-3,
|
||||
"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",
|
||||
)];
|
||||
/// What SolidCraft built: totals over every body.
|
||||
struct Got {
|
||||
body_count: usize,
|
||||
volume: f64,
|
||||
area: f64,
|
||||
com: [f64; 3],
|
||||
bbox: ([f64; 3], [f64; 3]),
|
||||
faces: u64,
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<[f64; 3]> {
|
||||
let a = v.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
}
|
||||
|
||||
fn totals(m: &Value) -> Got {
|
||||
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
|
||||
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
|
||||
let mut moment = [0.0; 3];
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for b in &bodies {
|
||||
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
|
||||
volume += v;
|
||||
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
|
||||
faces += b["faces"].as_u64().unwrap_or(0);
|
||||
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
|
||||
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
|
||||
for k in 0..3 {
|
||||
moment[k] += v * c[k];
|
||||
lo[k] = lo[k].min(bl[k]);
|
||||
hi[k] = hi[k].max(bh[k]);
|
||||
}
|
||||
}
|
||||
let com = moment.map(|x| x / volume);
|
||||
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
|
||||
}
|
||||
|
||||
struct Checks {
|
||||
list: Vec<Value>,
|
||||
pass: bool,
|
||||
}
|
||||
|
||||
impl Checks {
|
||||
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
|
||||
self.pass &= ok;
|
||||
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
|
||||
}
|
||||
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
|
||||
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
|
||||
self.push(what, json!(g), json!(w), ok);
|
||||
}
|
||||
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
|
||||
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
|
||||
self.push(what, json!(g), json!(w), ok);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_case(dir: &str) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let rp = format!("{dir}/recipe.json");
|
||||
let mp = format!("{dir}/measure.json");
|
||||
let (recipe, want) = match (read_json(&rp), read_json(&mp)) {
|
||||
(Ok(r), Ok(m)) => (r, m),
|
||||
(Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}),
|
||||
let recipe = match read_json(&format!("{dir}/recipe.json")) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let want = read_json(&format!("{dir}/expected.json")).ok();
|
||||
let script = match recipe::to_script(&recipe) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
|
||||
};
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
if let Err(e) = s.run_script(&script) {
|
||||
return json!({"case": name, "pass": false, "error": e.to_string()});
|
||||
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
|
||||
}
|
||||
let got = match s.execute("inspect.measure", &json!({})) {
|
||||
Ok(m) => m,
|
||||
Ok(m) => totals(&m),
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
|
||||
let ok = match (g, w) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
|
||||
let got_json = json!({
|
||||
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
|
||||
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
|
||||
});
|
||||
let Some(want) = want else {
|
||||
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
|
||||
};
|
||||
// Cases where Fusion's part is itself an approximation: compare size loosely, not topology.
|
||||
let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name);
|
||||
let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL);
|
||||
let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
let size_ok =
|
||||
got["body_count"] == want["body_count"] && within("volume_mm3", tol) && (FUSION_AREA_OFF.contains(&name.as_str()) || within("area_mm2", tol));
|
||||
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol);
|
||||
let area_off = FUSION_AREA_OFF.contains(&name.as_str());
|
||||
if !area_off {
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol);
|
||||
let only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
|
||||
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
|
||||
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
|
||||
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
|
||||
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
|
||||
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
|
||||
let mut c = Checks { list: Vec::new(), pass: true };
|
||||
if wanted("body_count") {
|
||||
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
|
||||
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
|
||||
}
|
||||
let mut seam_note = None;
|
||||
if approx.is_none() {
|
||||
let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]);
|
||||
let fusion_seams = want["bodies"]
|
||||
.as_array()
|
||||
.and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))));
|
||||
match fusion_seams {
|
||||
// Size matches and only the topology differs: compare with Fusion's seam splits merged.
|
||||
Some((f, e, v)) if size_ok && !exact => {
|
||||
let raw: Vec<String> = ["faces", "edges", "vertices"]
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum();
|
||||
format!("{k} {ours}/{}", want["total"][k])
|
||||
})
|
||||
.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 SolidCraft/Fusion: {})",
|
||||
want["total"]["faces"],
|
||||
want["total"]["edges"],
|
||||
want["total"]["vertices"],
|
||||
raw.join(", ")
|
||||
));
|
||||
num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0);
|
||||
num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0);
|
||||
num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0);
|
||||
}
|
||||
_ => {
|
||||
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
|
||||
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
|
||||
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
|
||||
}
|
||||
}
|
||||
if wanted("volume_mm3") {
|
||||
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
|
||||
}
|
||||
if wanted("area_mm2") {
|
||||
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
|
||||
}
|
||||
if wanted("center_of_mass") {
|
||||
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
|
||||
}
|
||||
if wanted("bbox") {
|
||||
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
|
||||
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
|
||||
}
|
||||
if wanted("faces") {
|
||||
let ok = want["faces"].as_u64() == Some(got.faces);
|
||||
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
|
||||
}
|
||||
let stl = format!("{dir}/reference.stl");
|
||||
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
|
||||
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
|
||||
reference_checks(&s, &stl, tol, &mut c);
|
||||
}
|
||||
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
|
||||
// same volume.
|
||||
let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0));
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok}));
|
||||
let mut out = json!({"case": name, "pass": pass, "checks": checks});
|
||||
if let Some((_, _, why)) = approx {
|
||||
out["note"] = json!(why);
|
||||
}
|
||||
if let Some(n) = seam_note {
|
||||
let (ok, note) = step_export_check(&s, got.volume);
|
||||
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
|
||||
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
|
||||
if let Some(n) = want["note"].as_str() {
|
||||
out["note"] = json!(n);
|
||||
}
|
||||
if area_off {
|
||||
out["note"] = json!(
|
||||
"area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared"
|
||||
);
|
||||
}
|
||||
let effective: Vec<String> = recipe["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|f| f.get("fusion_effective_extent").is_some())
|
||||
.map(|f| f["name"].as_str().unwrap_or("?").to_string())
|
||||
.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; SolidCraft's through-all goes truly through)",
|
||||
effective.join(", ")
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -163,77 +183,43 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a
|
||||
/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the
|
||||
/// volumes agree. Their area is not compared.
|
||||
const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
|
||||
|
||||
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
|
||||
/// body count, volume and area (relative 1e-3) and the faces read from the file.
|
||||
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
|
||||
fn check_step_case(dir: &str, iges: bool) -> Value {
|
||||
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
|
||||
let want = match read_json(&format!("{dir}/measure.json")) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e}),
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
let opened = match s.execute("doc.open", &json!({"path": sp})) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let got = match s.execute("inspect.measure", &json!({})) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
|
||||
};
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
|
||||
let file_faces = std::fs::read(&sp)
|
||||
.ok()
|
||||
.and_then(|b| {
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
if iges {
|
||||
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
|
||||
} else {
|
||||
solvecraft_engine::kernel::step_import_shared(&text).ok()
|
||||
/// Our part as a triangle soup (curved faces refined onto the surface).
|
||||
fn our_mesh(s: &solvecraft_engine::Session) -> Result<Vec<Tri>, String> {
|
||||
let mut ours: Vec<Tri> = Vec::new();
|
||||
for b in &s.world_state().bodies {
|
||||
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
|
||||
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
|
||||
for t in &m.triangles {
|
||||
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
|
||||
ours.push([a, b, c]);
|
||||
}
|
||||
})
|
||||
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
|
||||
let mut checks = Vec::new();
|
||||
let mut pass = true;
|
||||
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
|
||||
let ok = match (g, w) {
|
||||
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
|
||||
_ => false,
|
||||
};
|
||||
pass &= ok;
|
||||
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
|
||||
};
|
||||
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
|
||||
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
|
||||
if !FUSION_AREA_OFF.contains(&name.as_str()) {
|
||||
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
|
||||
}
|
||||
}
|
||||
num("faces", file_faces, want["total"]["faces"].as_f64(), 0.0);
|
||||
json!({
|
||||
"case": name, "pass": pass, "checks": checks, "ms": ms,
|
||||
"bodies": opened["bodies"], "warnings": opened["warnings"],
|
||||
"info": {"kernel_faces_merged": got["total"]["faces"], "edges": got["total"]["edges"], "want_edges": want["total"]["edges"], "vertices": got["total"]["vertices"], "want_vertices": want["total"]["vertices"]},
|
||||
})
|
||||
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
|
||||
}
|
||||
|
||||
pub fn step_corpus(args: &[String]) -> Result<(), String> {
|
||||
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if dirs.is_empty() {
|
||||
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
|
||||
}
|
||||
let iges = args.iter().any(|a| a == "--iges");
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).collect();
|
||||
report(args, &results);
|
||||
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
|
||||
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
|
||||
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
|
||||
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
|
||||
/// sits in space).
|
||||
fn reference_checks(s: &solvecraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
|
||||
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
|
||||
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
|
||||
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
|
||||
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
|
||||
};
|
||||
let (lo, hi) = spatial::bounds(&reference);
|
||||
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
|
||||
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
|
||||
let worst = h.a_to_b.max(h.b_to_a);
|
||||
c.push(
|
||||
"surface_distance_mm",
|
||||
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
|
||||
json!(format!("<= {tol}")),
|
||||
worst <= tol,
|
||||
);
|
||||
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
|
||||
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
|
||||
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
|
||||
}
|
||||
|
||||
fn report(args: &[String], results: &[Value]) {
|
||||
@@ -268,7 +254,7 @@ fn report(args: &[String], results: &[Value]) {
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
|
||||
if dirs.is_empty() {
|
||||
return Err("oracle: give one or more case directories".into());
|
||||
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
|
||||
}
|
||||
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
|
||||
report(args, &results);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
|
||||
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts.
|
||||
//!
|
||||
//! A recipe lists features with sketch entities in sketch-local coordinates; point references
|
||||
//! (`l1.end`) share points. Each feature becomes one or more commands.
|
||||
@@ -85,14 +85,6 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
|
||||
|
||||
/// Extent parameters for the Extrude command.
|
||||
fn extent(f: &Value, p: &mut Value) {
|
||||
// Where Fusion's own result differs from the geometric meaning (its through-all is sized
|
||||
// from vertices), the recipe records the extent Fusion actually used: replay that.
|
||||
if let Some(fx) = f.get("fusion_effective_extent").filter(|x| x.get("type").and_then(Value::as_str) == Some("distance")) {
|
||||
let dir = fx.get("direction").and_then(Value::as_str).unwrap_or("positive");
|
||||
p["distance"] = fx.get("distance").cloned().unwrap_or(Value::Null);
|
||||
p["direction"] = json!(if dir.starts_with("negative") { "negative" } else { "positive" });
|
||||
return;
|
||||
}
|
||||
let e = f.get("extent").cloned().unwrap_or(Value::Null);
|
||||
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
|
||||
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
|
||||
@@ -545,8 +537,8 @@ fn edge_points(f: &Value) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
|
||||
/// features are the recipe's own names.
|
||||
/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are
|
||||
/// the recipe's own names.
|
||||
fn feature_refs(v: Option<&Value>) -> Value {
|
||||
let list: Vec<Value> = v
|
||||
.and_then(Value::as_array)
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
//! Seam-normalised topology of Fusion's measurements.
|
||||
//!
|
||||
//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a
|
||||
//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged
|
||||
//! counts already count each closed surface patch once and ignore seam edges and vertices, so
|
||||
//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists:
|
||||
//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by
|
||||
//! seam edges count once;
|
||||
//! - a seam edge lies on that surface and on no other face's surface: it is dropped;
|
||||
//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more):
|
||||
//! the two edges join, and a curve that closes on itself keeps one vertex.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
type V3 = [f64; 3];
|
||||
|
||||
const TOL: f64 = 1e-4;
|
||||
|
||||
fn v3(v: &Value) -> Option<V3> {
|
||||
let a = v.as_array()?;
|
||||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
|
||||
}
|
||||
fn sub(a: V3, b: V3) -> V3 {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: V3, b: V3) -> V3 {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn mul(a: V3, s: f64) -> V3 {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: V3, b: V3) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: V3, b: V3) -> V3 {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
fn len(a: V3) -> f64 {
|
||||
dot(a, a).sqrt()
|
||||
}
|
||||
fn unit(a: V3) -> Option<V3> {
|
||||
let l = len(a);
|
||||
(l > 1e-12).then(|| mul(a, 1.0 / l))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Surf {
|
||||
Plane { p: V3, n: V3 },
|
||||
Cylinder { o: V3, a: V3, r: f64 },
|
||||
Cone { o: V3, a: V3, r: f64, tan: f64 },
|
||||
Sphere { c: V3, r: f64 },
|
||||
Torus { c: V3, a: V3, big: f64, small: f64 },
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Surf {
|
||||
fn of(f: &Value) -> Surf {
|
||||
let g = |k: &str| v3(&f[k]);
|
||||
let n = |k: &str| f[k].as_f64();
|
||||
let s = match f["type"].as_str().unwrap_or_default() {
|
||||
"plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }),
|
||||
"cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(),
|
||||
"cone" => {
|
||||
(|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))(
|
||||
)
|
||||
}
|
||||
"sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(),
|
||||
"torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(),
|
||||
_ => None,
|
||||
};
|
||||
s.unwrap_or(Surf::Other)
|
||||
}
|
||||
|
||||
fn periodic(&self) -> bool {
|
||||
matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. })
|
||||
}
|
||||
|
||||
/// Whether the point lies on the surface; `None` when that can't be told (free-form faces).
|
||||
fn contains(&self, p: V3) -> Option<bool> {
|
||||
let radial = |o: V3, a: V3| {
|
||||
let d = sub(p, o);
|
||||
let t = dot(d, a);
|
||||
(t, len(sub(d, mul(a, t))))
|
||||
};
|
||||
Some(match *self {
|
||||
Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL,
|
||||
Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL,
|
||||
Surf::Cone { o, a, r, tan } => {
|
||||
let (t, rho) = radial(o, a);
|
||||
(rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL
|
||||
}
|
||||
Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL,
|
||||
Surf::Torus { c, a, big, small } => {
|
||||
let (t, rho) = radial(c, a);
|
||||
((rho - big).hypot(t) - small).abs() < TOL
|
||||
}
|
||||
Surf::Other => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn same(&self, o: &Surf) -> bool {
|
||||
let close = |a: f64, b: f64| (a - b).abs() < TOL;
|
||||
let pt = |a: V3, b: V3| len(sub(a, b)) < TOL;
|
||||
let par = |a: V3, b: V3| len(cross(a, b)) < TOL;
|
||||
// Same axis line: parallel, and either origin on the other's axis.
|
||||
let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL;
|
||||
match (*self, *o) {
|
||||
(Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2),
|
||||
(Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => {
|
||||
// Same cone: the other's frame circle lies on this one, same opening angle.
|
||||
let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false };
|
||||
axis(o1, a1, o2, a2) && close(t1, t2) && {
|
||||
let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum();
|
||||
close(r2, r1 + t * t1) || close(r2, r1 - t * t1)
|
||||
}
|
||||
}
|
||||
(Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2),
|
||||
(Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => {
|
||||
pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Points along an edge from Fusion's edge list.
|
||||
fn edge_points(e: &Value) -> Vec<V3> {
|
||||
let (s, t) = (v3(&e["start"]), v3(&e["end"]));
|
||||
let mut pts: Vec<V3> = s.into_iter().chain(t).collect();
|
||||
let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit));
|
||||
match (e["type"].as_str().unwrap_or_default(), s, t) {
|
||||
("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)),
|
||||
("arc", Some(s), Some(t)) => {
|
||||
if let (Some(c), Some(r)) = (c, r)
|
||||
&& let Some(m) = unit(add(sub(s, c), sub(t, c)))
|
||||
{
|
||||
let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r);
|
||||
pts.push(add(c, mul(m, if long { -r } else { r })));
|
||||
}
|
||||
}
|
||||
("circle", Some(s), _) => {
|
||||
if let (Some(c), Some(a)) = (c, a) {
|
||||
let d = sub(s, c);
|
||||
for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] {
|
||||
pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
fn key(p: V3) -> [i64; 3] {
|
||||
p.map(|x| (x * 1e6).round() as i64)
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
break;
|
||||
}
|
||||
i = q;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the
|
||||
/// lists are missing.
|
||||
pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> {
|
||||
let faces = body["face_list"].as_array()?;
|
||||
let edges = body["edge_list"].as_array()?;
|
||||
let surfs: Vec<Surf> = faces.iter().map(Surf::of).collect();
|
||||
// Group faces cut from one periodic surface.
|
||||
let mut group: Vec<usize> = (0..surfs.len()).collect();
|
||||
for i in 0..surfs.len() {
|
||||
for j in 0..i {
|
||||
if surfs[i].periodic() && surfs[i].same(&surfs[j]) {
|
||||
group[i] = group[j];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let size = |g: usize| group.iter().filter(|x| **x == g).count();
|
||||
// Seam edges: on a split group's surface and on no other face's surface.
|
||||
let mut seams_in: HashMap<usize, usize> = HashMap::new();
|
||||
let mut seam = vec![false; edges.len()];
|
||||
for (ei, e) in edges.iter().enumerate() {
|
||||
let pts = edge_points(e);
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?));
|
||||
let carriers: Vec<Option<bool>> = surfs.iter().map(on).collect();
|
||||
if carriers.contains(&None) {
|
||||
continue;
|
||||
}
|
||||
let groups: Vec<usize> = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect();
|
||||
if let Some(&g) = groups.first()
|
||||
&& groups.iter().all(|x| *x == g)
|
||||
&& surfs[g].periodic()
|
||||
&& size(g) > 1
|
||||
{
|
||||
seam[ei] = true;
|
||||
*seams_in.entry(g).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
let mut face_count = 0;
|
||||
for g in 0..surfs.len() {
|
||||
let k = size(g);
|
||||
if k > 0 {
|
||||
face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1);
|
||||
}
|
||||
}
|
||||
// Vertex degrees over the remaining edges; a closed edge counts once at its vertex.
|
||||
let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new();
|
||||
for (ei, e) in edges.iter().enumerate() {
|
||||
if seam[ei] {
|
||||
continue;
|
||||
}
|
||||
let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key));
|
||||
let t = if t == s { None } else { t };
|
||||
ends.push((ends.len(), s, t));
|
||||
}
|
||||
// A seam between two closed curves (a closed face's seam whose surface we can't evaluate):
|
||||
// an open edge whose ends each meet only a closed edge. At a real vertex three edges meet.
|
||||
let closed_at = |v: &[i64; 3], ends: &[(usize, Option<[i64; 3]>, Option<[i64; 3]>)]| {
|
||||
let closed = ends.iter().filter(|(_, s, t)| t.is_none() && s.as_ref() == Some(v)).count();
|
||||
let open = ends.iter().filter(|(_, s, t)| t.is_some() && (s.as_ref() == Some(v) || t.as_ref() == Some(v))).count();
|
||||
closed == 1 && open == 1
|
||||
};
|
||||
let loop_seams: Vec<usize> =
|
||||
ends.iter().filter(|(_, s, t)| matches!((s, t), (Some(s), Some(t)) if closed_at(s, &ends) && closed_at(t, &ends))).map(|e| e.0).collect();
|
||||
ends.retain(|e| !loop_seams.contains(&e.0));
|
||||
for (k, e) in ends.iter_mut().enumerate() {
|
||||
e.0 = k;
|
||||
}
|
||||
let mut deg: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
|
||||
for (i, s, t) in &ends {
|
||||
for v in [s, t].into_iter().flatten() {
|
||||
deg.entry(*v).or_default().push(*i);
|
||||
}
|
||||
}
|
||||
let mut ep: Vec<usize> = (0..ends.len()).collect();
|
||||
let mut merged: Vec<[i64; 3]> = Vec::new();
|
||||
for (v, es) in ° {
|
||||
// Two open edges meeting: one curve split at a seam.
|
||||
if let [i, j] = es[..]
|
||||
&& i != j
|
||||
&& ends.get(i).is_some_and(|e| e.2.is_some())
|
||||
&& ends.get(j).is_some_and(|e| e.2.is_some())
|
||||
{
|
||||
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
|
||||
if let Some(x) = ep.get_mut(ri) {
|
||||
*x = rj;
|
||||
}
|
||||
merged.push(*v);
|
||||
}
|
||||
}
|
||||
let mut real: HashMap<usize, bool> = HashMap::new();
|
||||
for (i, s, t) in &ends {
|
||||
let r = find(&mut ep, *i);
|
||||
let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none());
|
||||
*real.entry(r).or_insert(false) |= has_real;
|
||||
}
|
||||
let closed = real.values().filter(|r| !**r).count();
|
||||
let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed;
|
||||
Some((face_count, real.len(), vertices))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// A cylinder (r 5, height 10) split by Fusion into two halves along two seams.
|
||||
#[test]
|
||||
fn split_cylinder_counts_once() {
|
||||
let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0});
|
||||
let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]});
|
||||
let arc = |z: f64, s: [f64; 3], t: [f64; 3]| json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963});
|
||||
let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]});
|
||||
let body = json!({
|
||||
"face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)],
|
||||
"edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])],
|
||||
});
|
||||
assert_eq!(normalised(&body), Some((3, 2, 2)));
|
||||
}
|
||||
|
||||
/// Oracle 35: a fillet (free-form face, surface unknown) between two cylinders is bounded by
|
||||
/// two closed contact curves joined by its seam arc. The arc goes; the curves and circles stay.
|
||||
#[test]
|
||||
fn seam_between_closed_curves_on_an_unknown_surface() {
|
||||
let circle =
|
||||
|s: [f64; 3], c: [f64; 3], a: [f64; 3]| json!({"type": "circle", "start": s, "end": s, "center": c, "radius_mm": 1.0, "axis": a});
|
||||
let lp = |p: [f64; 3]| json!({"type": "nurbs", "start": p, "end": p});
|
||||
let (p, q) = ([0.0, -10.833333333, 10.374916332], [0.0, -10.0, 12.449899598]);
|
||||
let body = json!({
|
||||
"face_list": [{"type": "nurbs"}],
|
||||
"edge_list": [
|
||||
{"type": "arc", "start": p, "end": q, "center": [0.0, -13.0, 12.449899598], "radius_mm": 3.0, "axis": [1, 0, 0]},
|
||||
circle([-40.0, 0.0, 15.0], [-40.0, 0.0, 0.0], [-1.0, 0.0, 0.0]),
|
||||
circle([-10.0, 0.0, 35.0], [0.0, 0.0, 35.0], [0.0, 0.0, 1.0]),
|
||||
circle([40.0, 0.0, 15.0], [40.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
|
||||
lp(p),
|
||||
lp(q),
|
||||
],
|
||||
});
|
||||
assert_eq!(normalised(&body), Some((1, 5, 5)));
|
||||
}
|
||||
|
||||
/// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway).
|
||||
#[test]
|
||||
fn degree_two_vertices_join() {
|
||||
let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t});
|
||||
let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]});
|
||||
assert_eq!(normalised(&body), Some((0, 1, 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
|
||||
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
|
||||
//! triangle meshes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A triangle soup in mm.
|
||||
pub type Tri = [[f64; 3]; 3];
|
||||
|
||||
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
/// Read a binary (or ASCII) STL file.
|
||||
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if b.len() >= 84 {
|
||||
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
|
||||
if b.len() == 84 + 50 * n {
|
||||
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
|
||||
return Ok((0..n)
|
||||
.map(|i| {
|
||||
let o = 84 + 50 * i + 12;
|
||||
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
|
||||
[v(0), v(1), v(2)]
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
}
|
||||
let text = String::from_utf8_lossy(&b);
|
||||
let mut verts = Vec::new();
|
||||
for line in text.lines() {
|
||||
let mut it = line.split_whitespace();
|
||||
if it.next() == Some("vertex") {
|
||||
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
|
||||
if c.len() == 3 {
|
||||
verts.push([c[0], c[1], c[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if verts.is_empty() || verts.len() % 3 != 0 {
|
||||
return Err(format!("{path}: not an STL file"));
|
||||
}
|
||||
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
|
||||
}
|
||||
|
||||
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
|
||||
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
|
||||
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
|
||||
let (mut v6, mut m1) = (0.0, [0.0; 3]);
|
||||
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
|
||||
for t in tris {
|
||||
let d = dot(t[0], cross(t[1], t[2]));
|
||||
v6 += d;
|
||||
let s = add(add(t[0], t[1]), t[2]);
|
||||
m1 = add(m1, scale(s, d / 24.0));
|
||||
for (i, row) in c.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
|
||||
*cell += d / 120.0 * vv;
|
||||
}
|
||||
}
|
||||
}
|
||||
let vol = v6 / 6.0;
|
||||
if vol.abs() < 1e-12 {
|
||||
return (0.0, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
let com = scale(m1, 1.0 / vol);
|
||||
// Central covariance and inertia tensor I = tr(C)·1 − C.
|
||||
let mut cc = c;
|
||||
for (i, row) in cc.iter_mut().enumerate() {
|
||||
for (j, cell) in row.iter_mut().enumerate() {
|
||||
*cell -= vol * com[i] * com[j];
|
||||
}
|
||||
}
|
||||
let tr = cc[0][0] + cc[1][1] + cc[2][2];
|
||||
let mut inertia = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
|
||||
}
|
||||
}
|
||||
let mut ev = eigen_sym3(inertia);
|
||||
ev.sort_by(f64::total_cmp);
|
||||
(vol, com, ev)
|
||||
}
|
||||
|
||||
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
|
||||
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
|
||||
for _ in 0..50 {
|
||||
let (mut p, mut q, mut big) = (0, 1, 0.0);
|
||||
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
|
||||
if a[i][j].abs() > big {
|
||||
(p, q, big) = (i, j, a[i][j].abs());
|
||||
}
|
||||
}
|
||||
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
|
||||
break;
|
||||
}
|
||||
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
|
||||
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
|
||||
let t = if theta == 0.0 { 1.0 } else { t };
|
||||
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
|
||||
let mut b = a;
|
||||
for k in 0..3 {
|
||||
b[k][p] = cs * a[k][p] - sn * a[k][q];
|
||||
b[k][q] = sn * a[k][p] + cs * a[k][q];
|
||||
}
|
||||
let mut c2 = b;
|
||||
for k in 0..3 {
|
||||
c2[p][k] = cs * b[p][k] - sn * b[q][k];
|
||||
c2[q][k] = sn * b[p][k] + cs * b[q][k];
|
||||
}
|
||||
a = c2;
|
||||
}
|
||||
[a[0][0], a[1][1], a[2][2]]
|
||||
}
|
||||
|
||||
/// Axis-aligned bounds of a triangle soup.
|
||||
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in tris.iter().flatten() {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
|
||||
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
|
||||
let (a, b, c) = (t[0], t[1], t[2]);
|
||||
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
|
||||
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
|
||||
let q = if d1 <= 0.0 && d2 <= 0.0 {
|
||||
a
|
||||
} else {
|
||||
let bp = sub(p, b);
|
||||
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
b
|
||||
} else {
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
add(a, scale(ab, d1 / (d1 - d3)))
|
||||
} else {
|
||||
let cp = sub(p, c);
|
||||
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
c
|
||||
} else {
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
add(a, scale(ac, d2 / (d2 - d6)))
|
||||
} else {
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
|
||||
} else {
|
||||
let den = va + vb + vc;
|
||||
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let d = sub(p, q);
|
||||
dot(d, d).sqrt()
|
||||
}
|
||||
|
||||
/// Uniform grid of triangles for distance queries up to `cap`.
|
||||
struct Grid<'a> {
|
||||
tris: &'a [Tri],
|
||||
cell: f64,
|
||||
cells: HashMap<(i64, i64, i64), Vec<u32>>,
|
||||
}
|
||||
|
||||
impl<'a> Grid<'a> {
|
||||
fn new(tris: &'a [Tri], cell: f64) -> Self {
|
||||
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
|
||||
let key = |x: f64| (x / cell).floor() as i64;
|
||||
for (i, t) in tris.iter().enumerate() {
|
||||
let (lo, hi) = bounds(std::slice::from_ref(t));
|
||||
for x in key(lo[0])..=key(hi[0]) {
|
||||
for y in key(lo[1])..=key(hi[1]) {
|
||||
for z in key(lo[2])..=key(hi[2]) {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Grid { tris, cell, cells }
|
||||
}
|
||||
|
||||
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
|
||||
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
|
||||
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
|
||||
let rmax = (cap / self.cell).ceil() as i64;
|
||||
let k = |x: f64| (x / self.cell).floor() as i64;
|
||||
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
|
||||
let mut best = cap;
|
||||
for r in 0..=rmax {
|
||||
for x in cx - r..=cx + r {
|
||||
for y in cy - r..=cy + r {
|
||||
for z in cz - r..=cz + r {
|
||||
// only the shell at Chebyshev distance r
|
||||
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
|
||||
continue;
|
||||
}
|
||||
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
|
||||
for &i in list {
|
||||
let Some(slot) = seen.get_mut(i as usize) else { continue };
|
||||
if *slot == stamp {
|
||||
continue;
|
||||
}
|
||||
*slot = stamp;
|
||||
if let Some(t) = self.tris.get(i as usize) {
|
||||
best = best.min(point_tri(p, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Everything not yet searched is at least r·cell away from p.
|
||||
if best <= r as f64 * self.cell {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many sample points per side (larger meshes are sampled with an even stride).
|
||||
const MAX_SAMPLES: usize = 250_000;
|
||||
|
||||
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
|
||||
/// very large meshes).
|
||||
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
|
||||
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
|
||||
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
|
||||
for t in tris.iter().step_by(stride) {
|
||||
out.extend_from_slice(t);
|
||||
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
|
||||
/// capped at `cap`.
|
||||
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
|
||||
let (lo, hi) = bounds(to);
|
||||
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
|
||||
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
|
||||
// finer than diag/400 (so big flat triangles don't fill too many cells).
|
||||
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
|
||||
let typical = (area / to.len().max(1) as f64).sqrt();
|
||||
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
|
||||
let mut seen = vec![0u32; to.len()];
|
||||
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
|
||||
for (i, p) in samples(from).into_iter().enumerate() {
|
||||
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
|
||||
sum += d;
|
||||
n += 1;
|
||||
if d > max {
|
||||
max = d;
|
||||
worst = p;
|
||||
}
|
||||
}
|
||||
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
|
||||
}
|
||||
|
||||
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
|
||||
pub struct Hausdorff {
|
||||
pub a_to_b: f64,
|
||||
pub b_to_a: f64,
|
||||
pub mean: f64,
|
||||
/// Where the larger one-sided distance was found (mm, world).
|
||||
pub worst_at: [f64; 3],
|
||||
}
|
||||
|
||||
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
|
||||
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
|
||||
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
|
||||
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
|
||||
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
|
||||
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
|
||||
let mut t = Vec::new();
|
||||
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
|
||||
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
|
||||
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
|
||||
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
|
||||
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
|
||||
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_moments_and_distance() {
|
||||
let c = cube(10.0, [5.0, -3.0, 2.0]);
|
||||
let (v, com, pm) = mass_properties(&c);
|
||||
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
|
||||
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
|
||||
for m in pm {
|
||||
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
|
||||
}
|
||||
let moved = cube(10.0, [5.0, -3.0, 2.3]);
|
||||
let h = hausdorff(&c, &moved, 1.0);
|
||||
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
|
||||
let same = hausdorff(&c, &c, 1.0);
|
||||
assert!(same.a_to_b < 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ hostile variants of it never panic.
|
||||
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
|
||||
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
|
||||
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
|
||||
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
|
||||
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
|
||||
|
||||
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
|
||||
point is that it was written by the old one. Add new files for new eras instead.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 4078.7680390666596,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.000000000000014,
|
||||
12.000000000000002
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
19.25890231218174,
|
||||
15.000000010054242,
|
||||
5.04117761026949
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 30,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 11
|
||||
},
|
||||
"faces": 12,
|
||||
"kernel": {
|
||||
"edges": 30,
|
||||
"faces": 12,
|
||||
"shells": 1,
|
||||
"vertices": 20
|
||||
},
|
||||
"mass_g": 14.435720327850033,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11190.480874302351
|
||||
}
|
||||
],
|
||||
"body_count": 1,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.09895799999999999,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 20.768292000000002,
|
||||
"name": "Block",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.057,
|
||||
"name": "SlotSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 136.28875,
|
||||
"name": "Slot",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 153.58625,
|
||||
"name": "Round",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 169.174375,
|
||||
"name": "Bevel",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 7,
|
||||
"ms": 47.772166,
|
||||
"name": "Raise",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "OffsetFacesFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 4078.7680390666596,
|
||||
"edges": 30,
|
||||
"faces": 12,
|
||||
"mass_g": 14.435720327850033,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 11190.480874302351
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"format": "solvecraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
40.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
40.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"type": "coincident",
|
||||
"p": 0,
|
||||
"q": 1
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 5,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Block",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
15.0,
|
||||
-5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
25.0,
|
||||
-5.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
25.0,
|
||||
35.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
15.0,
|
||||
35.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 4,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Slot",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d2",
|
||||
"d3"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive",
|
||||
"start_offset": "5"
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Round",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
-1.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l2|F2:top#2"
|
||||
],
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
40.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "2"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "Bevel",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d5"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.000000000000007,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l4|F2:top#1"
|
||||
],
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
0.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1.5"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"name": "Raise",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d6"
|
||||
],
|
||||
"face_names": [
|
||||
"F2:top#1"
|
||||
],
|
||||
"type": "offset_face",
|
||||
"faces": [
|
||||
[
|
||||
7.0,
|
||||
15.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "2"
|
||||
}
|
||||
],
|
||||
"next_id": 8
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": 10,
|
||||
"name": "Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "SlotSketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
15,
|
||||
-5
|
||||
],
|
||||
"p1": [
|
||||
25,
|
||||
35
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"sketch": "SlotSketch",
|
||||
"distance": 10,
|
||||
"start_offset": 5,
|
||||
"operation": "cut",
|
||||
"name": "Slot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
40,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"radius": 2,
|
||||
"name": "Round"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.chamfer",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
0,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 1.5,
|
||||
"name": "Bevel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.offset_face",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
7,
|
||||
15,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 2,
|
||||
"name": "Raise"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Base"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"p1": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": 10,
|
||||
"name": "Block"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "SlotSketch"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.rectangle.two_point",
|
||||
"params": {
|
||||
"p0": [
|
||||
-5,
|
||||
12
|
||||
],
|
||||
"p1": [
|
||||
45,
|
||||
18
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"sketch": "SlotSketch",
|
||||
"distance": 10,
|
||||
"operation": "cut",
|
||||
"name": "Slot"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
20,
|
||||
0,
|
||||
10
|
||||
]
|
||||
],
|
||||
"radius": 2,
|
||||
"body": "Body1 (1)",
|
||||
"name": "NearRound"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "solid.chamfer",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
20,
|
||||
30,
|
||||
10
|
||||
]
|
||||
],
|
||||
"distance": 1.5,
|
||||
"body": "Body1",
|
||||
"name": "FarBevel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"command": "appearance.assign",
|
||||
"params": {
|
||||
"bodies": [
|
||||
"Body1 (1)"
|
||||
],
|
||||
"color": "#cc3333"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"bodies": [
|
||||
{
|
||||
"area_mm2": 1962.6028137423857,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
18.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.0,
|
||||
23.94794952681388,
|
||||
4.957413249211356
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 15,
|
||||
"face_types": {
|
||||
"plane": 7
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 15,
|
||||
"faces": 7,
|
||||
"shells": 1,
|
||||
"vertices": 10
|
||||
},
|
||||
"mass_g": 6.13395,
|
||||
"material": "Default",
|
||||
"name": "Body1",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 4755.0
|
||||
},
|
||||
{
|
||||
"area_mm2": 1963.9401350108699,
|
||||
"bbox": {
|
||||
"max": [
|
||||
40.00000000000002,
|
||||
12.000000000000002,
|
||||
10.0
|
||||
],
|
||||
"min": [
|
||||
-8.881784197001252e-15,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"center_of_mass": [
|
||||
20.00000000000079,
|
||||
6.040035701025577,
|
||||
4.967173993940324
|
||||
],
|
||||
"density_g_cm3": 1.29,
|
||||
"edges": 15,
|
||||
"face_types": {
|
||||
"cylinder": 1,
|
||||
"plane": 6
|
||||
},
|
||||
"faces": 7,
|
||||
"kernel": {
|
||||
"edges": 15,
|
||||
"faces": 7,
|
||||
"shells": 1,
|
||||
"vertices": 10
|
||||
},
|
||||
"mass_g": 6.147677129601969,
|
||||
"material": "Default",
|
||||
"name": "Body1 (1)",
|
||||
"vertices": 10,
|
||||
"volume_mm3": 4765.64118573796
|
||||
}
|
||||
],
|
||||
"body_count": 2,
|
||||
"params": [],
|
||||
"sketches": [
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 2,
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"curves": 4,
|
||||
"dof": 4,
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"profiles": 1,
|
||||
"status": "solved",
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"error": null,
|
||||
"id": 1,
|
||||
"ms": 0.07066700000000001,
|
||||
"name": "Base",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 2,
|
||||
"ms": 0.7462909999999999,
|
||||
"name": "Block",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 3,
|
||||
"ms": 0.035125,
|
||||
"name": "SlotSketch",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "Sketch",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 4,
|
||||
"ms": 9.647208000000001,
|
||||
"name": "Slot",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ExtrudeFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 5,
|
||||
"ms": 3.443708,
|
||||
"name": "NearRound",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "FilletFeature",
|
||||
"warning": null
|
||||
},
|
||||
{
|
||||
"error": null,
|
||||
"id": 6,
|
||||
"ms": 3.947834,
|
||||
"name": "FarBevel",
|
||||
"rolled_back": false,
|
||||
"suppressed": false,
|
||||
"type": "ChamferFeature",
|
||||
"warning": null
|
||||
}
|
||||
],
|
||||
"total": {
|
||||
"area_mm2": 3926.5429487532556,
|
||||
"edges": 30,
|
||||
"faces": 14,
|
||||
"mass_g": 12.281627129601969,
|
||||
"vertices": 20,
|
||||
"volume_mm3": 9520.64118573796
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
{
|
||||
"format": "solvecraft/13",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
"features": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Base",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
40.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
40.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
0.0,
|
||||
30.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
},
|
||||
{
|
||||
"id": "k5",
|
||||
"type": "coincident",
|
||||
"p": 0,
|
||||
"q": 1
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 5,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "Block",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d1"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 1,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "new_body"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name": "SlotSketch",
|
||||
"suppressed": false,
|
||||
"type": "sketch",
|
||||
"plane": {
|
||||
"type": "origin",
|
||||
"name": "XY"
|
||||
},
|
||||
"sketch": {
|
||||
"points": [
|
||||
{
|
||||
"id": "origin",
|
||||
"pos": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"fixed": true
|
||||
},
|
||||
{
|
||||
"id": "p1",
|
||||
"pos": [
|
||||
-5.0,
|
||||
12.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"pos": [
|
||||
45.0,
|
||||
12.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"pos": [
|
||||
45.0,
|
||||
18.0
|
||||
],
|
||||
"fixed": false
|
||||
},
|
||||
{
|
||||
"id": "p4",
|
||||
"pos": [
|
||||
-5.0,
|
||||
18.0
|
||||
],
|
||||
"fixed": false
|
||||
}
|
||||
],
|
||||
"curves": [
|
||||
{
|
||||
"id": "l1",
|
||||
"type": "line",
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"type": "line",
|
||||
"a": 2,
|
||||
"b": 3,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l3",
|
||||
"type": "line",
|
||||
"a": 3,
|
||||
"b": 4,
|
||||
"construction": false
|
||||
},
|
||||
{
|
||||
"id": "l4",
|
||||
"type": "line",
|
||||
"a": 4,
|
||||
"b": 1,
|
||||
"construction": false
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"id": "k1",
|
||||
"type": "horizontal",
|
||||
"l": 0
|
||||
},
|
||||
{
|
||||
"id": "k2",
|
||||
"type": "horizontal",
|
||||
"l": 2
|
||||
},
|
||||
{
|
||||
"id": "k3",
|
||||
"type": "vertical",
|
||||
"l": 1
|
||||
},
|
||||
{
|
||||
"id": "k4",
|
||||
"type": "vertical",
|
||||
"l": 3
|
||||
}
|
||||
],
|
||||
"counters": {
|
||||
"k": 4,
|
||||
"l": 4,
|
||||
"p": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Slot",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d2"
|
||||
],
|
||||
"type": "extrude",
|
||||
"sketch": 3,
|
||||
"profiles": {
|
||||
"type": "all"
|
||||
},
|
||||
"extent": {
|
||||
"distance": "10",
|
||||
"direction": "positive"
|
||||
},
|
||||
"operation": "cut"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "NearRound",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d3"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
-1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
12.000000000000002,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l1|F2:top"
|
||||
],
|
||||
"type": "fillet",
|
||||
"edges": [
|
||||
[
|
||||
20.0,
|
||||
0.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"radius": "2",
|
||||
"body": "Body1 (1)"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"name": "FarBevel",
|
||||
"suppressed": false,
|
||||
"param_names": [
|
||||
"d4"
|
||||
],
|
||||
"edge_refs": [
|
||||
{
|
||||
"dir": [
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"min": [
|
||||
0.0,
|
||||
18.0,
|
||||
0.0
|
||||
],
|
||||
"max": [
|
||||
40.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
}
|
||||
],
|
||||
"edge_names": [
|
||||
"F2:side:l3|F2:top"
|
||||
],
|
||||
"type": "chamfer",
|
||||
"edges": [
|
||||
[
|
||||
20.0,
|
||||
30.0,
|
||||
10.0
|
||||
]
|
||||
],
|
||||
"distance": "1.5",
|
||||
"body": "Body1"
|
||||
}
|
||||
],
|
||||
"next_id": 7,
|
||||
"appearances": {
|
||||
"bodies": {
|
||||
"Body1 (1)": {
|
||||
"name": "Custom",
|
||||
"color": [
|
||||
204,
|
||||
51,
|
||||
51
|
||||
],
|
||||
"opacity": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,44 @@ fn old_designs_open_and_evaluate_as_saved() {
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
|
||||
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
|
||||
/// they are found as the build that saved them found them, without new notes.
|
||||
#[test]
|
||||
fn names_saved_before_exact_history_find_the_faces_they_named() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solvecraft");
|
||||
let s = open(&path);
|
||||
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
|
||||
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
|
||||
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
// The raised piece is the left one, as saved (the stored point is on it).
|
||||
let state = s.model.state();
|
||||
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
|
||||
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
|
||||
}
|
||||
|
||||
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
|
||||
/// The features after the split that refer to a piece by its body name still find that piece:
|
||||
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
|
||||
#[test]
|
||||
fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solvecraft");
|
||||
let s = open(&path);
|
||||
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
|
||||
for r in &s.model.results {
|
||||
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
|
||||
}
|
||||
let state = s.model.state();
|
||||
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
|
||||
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solvecraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
|
||||
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
|
||||
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
// A port that was free a moment ago: nothing listens on it.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
|
||||
}
|
||||
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
|
||||
] }
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>SolidCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="copy-file" href="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
#solvecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -46,6 +46,10 @@ mod web {
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
@@ -58,6 +62,9 @@ mod web {
|
||||
rename_focus: bool,
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solvecraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -304,10 +311,42 @@ mod web {
|
||||
}
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
|
||||
/// session falls back to English (no tofu); the saved language preference is kept.
|
||||
fn tick_font(&mut self, ctx: &egui::Context) {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solvecraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if installed {
|
||||
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
|
||||
} else {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
@@ -323,7 +362,11 @@ mod web {
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(PREFS_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +377,19 @@ mod web {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
|
||||
Ok(uint8_arr.to_vec())
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -355,6 +411,7 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
@@ -398,6 +455,8 @@ mod web {
|
||||
renaming: None,
|
||||
rename_focus: false,
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,10 @@ rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[build-dependencies]
|
||||
embed-resource = "3.0.11"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#[path = "../app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("cargo:rerun-if-changed=../app_icon.rs");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
|
||||
return Ok(());
|
||||
}
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
|
||||
let sizes = [16usize, 24, 32, 48, 64, 128, 256];
|
||||
let mut images = Vec::new();
|
||||
for size in sizes {
|
||||
let rgba = app_icon::rgba(size)?;
|
||||
// ICO DIB: bottom-up BGRA plus a padded one-bit transparency mask.
|
||||
let mask_stride = size.div_ceil(32) * 4;
|
||||
let mut dib = Vec::new();
|
||||
dib.extend_from_slice(&40u32.to_le_bytes());
|
||||
dib.extend_from_slice(&(size as i32).to_le_bytes());
|
||||
dib.extend_from_slice(&((size * 2) as i32).to_le_bytes());
|
||||
dib.extend_from_slice(&1u16.to_le_bytes());
|
||||
dib.extend_from_slice(&32u16.to_le_bytes());
|
||||
dib.extend_from_slice(&[0; 24]);
|
||||
for row in rgba.chunks_exact(size * 4).rev() {
|
||||
for p in row.as_chunks::<4>().0.iter() {
|
||||
dib.extend_from_slice(&[p[2], p[1], p[0], p[3]]);
|
||||
}
|
||||
}
|
||||
for row in rgba.chunks_exact(size * 4).rev() {
|
||||
let mut mask = vec![0u8; mask_stride];
|
||||
for (x, p) in row.as_chunks::<4>().0.iter().enumerate() {
|
||||
if p[3] == 0 {
|
||||
mask[x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
dib.extend_from_slice(&mask);
|
||||
}
|
||||
images.push((size, dib));
|
||||
}
|
||||
let mut ico = vec![0, 0, 1, 0];
|
||||
ico.extend_from_slice(&(images.len() as u16).to_le_bytes());
|
||||
let mut offset = 6 + 16 * images.len();
|
||||
for (size, data) in &images {
|
||||
let dim = if *size == 256 { 0 } else { *size as u8 };
|
||||
ico.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
|
||||
ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
ico.extend_from_slice(&(offset as u32).to_le_bytes());
|
||||
offset += data.len();
|
||||
}
|
||||
for (_, data) in images {
|
||||
ico.extend_from_slice(&data);
|
||||
}
|
||||
let icon = out.join("solidcraft.ico");
|
||||
std::fs::write(&icon, ico)?;
|
||||
let rc = out.join("solidcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
|
||||
// Missing resource tools are a build error, rather than silently shipping a generic icon.
|
||||
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
+94
-15
@@ -12,6 +12,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod space_mouse;
|
||||
@@ -124,6 +126,24 @@ const DEPTH_BITS: u8 = 24;
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
const MSAA: u16 = 4;
|
||||
|
||||
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(egui::IconData {
|
||||
rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?,
|
||||
width: 64,
|
||||
height: 64,
|
||||
})
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"))
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let samples = msaa();
|
||||
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
@@ -146,16 +166,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([960.0, 600.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id("ai.storyteller.solvecraft"),
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
@@ -164,10 +175,12 @@ fn main() -> eframe::Result {
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
|
||||
if prefer_x11(
|
||||
std::env::var_os("DISPLAY").is_some(),
|
||||
std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
|
||||
std::env::var("SOLVECRAFT_WAYLAND").ok().as_deref(),
|
||||
) {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
@@ -185,7 +198,8 @@ fn main() -> eframe::Result {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, u32::from(samples));
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -223,3 +237,68 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
|
||||
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
|
||||
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
|
||||
/// `SOLVECRAFT_WAYLAND=1` keeps native Wayland, `SOLVECRAFT_WAYLAND=0` forces XWayland.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
|
||||
match wayland_override.map(str::trim) {
|
||||
Some("0") => return display,
|
||||
Some(_) => return false,
|
||||
None => {}
|
||||
}
|
||||
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
|
||||
display && !(niri && wayland_display)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod backend_tests {
|
||||
use super::prefer_x11;
|
||||
|
||||
#[test]
|
||||
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
|
||||
// XWayland whenever there's an X display, for file drag-and-drop.
|
||||
assert!(prefer_x11(true, true, Some("GNOME"), None));
|
||||
assert!(prefer_x11(true, false, None, None));
|
||||
assert!(!prefer_x11(false, true, Some("KDE"), None));
|
||||
// niri (alone or in a desktop list) under Wayland stays native.
|
||||
assert!(!prefer_x11(true, true, Some("niri"), None));
|
||||
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
|
||||
// niri without a Wayland socket (nested X session) still uses X11.
|
||||
assert!(prefer_x11(true, false, Some("niri"), None));
|
||||
// SOLVECRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
|
||||
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
||||
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
#[test]
|
||||
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
|
||||
let viewport = super::app_viewport().expect("viewport");
|
||||
let icon = viewport.icon.expect("running window must have an icon");
|
||||
assert_eq!((icon.width, icon.height), (64, 64));
|
||||
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
|
||||
assert_eq!(viewport.app_id.as_deref(), Some("ai.storyteller.solvecraft"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
#[test]
|
||||
fn saved_multisampling_uses_the_selected_backend_policy() {
|
||||
use eframe::wgpu::Backend;
|
||||
for requested in [1, 2, 4, 8] {
|
||||
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
|
||||
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
|
||||
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
@@ -18,6 +21,172 @@ struct Axes {
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ProfileAxis {
|
||||
input: Option<usize>,
|
||||
output: Option<usize>,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum ProfilePart {
|
||||
Input,
|
||||
Output,
|
||||
}
|
||||
|
||||
fn axis_index(name: &str) -> Option<usize> {
|
||||
match name.trim() {
|
||||
"HIDMultiAxis_X" => Some(0),
|
||||
"HIDMultiAxis_Y" => Some(1),
|
||||
"HIDMultiAxis_Z" => Some(2),
|
||||
"HIDMultiAxis_Rx" => Some(3),
|
||||
"HIDMultiAxis_Ry" => Some(4),
|
||||
"HIDMultiAxis_Rz" => Some(5),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
|
||||
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
|
||||
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
|
||||
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
|
||||
let mut reader = Reader::from_reader(xml);
|
||||
reader.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut mapping = DriverMapping::default();
|
||||
let mut axis: Option<ProfileAxis> = None;
|
||||
let mut part = None;
|
||||
let mut field = Vec::new();
|
||||
let mut found = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf).ok()? {
|
||||
Event::Start(start) => match start.local_name().as_ref() {
|
||||
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
|
||||
b"Input" => part = Some(ProfilePart::Input),
|
||||
b"Output" => part = Some(ProfilePart::Output),
|
||||
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
let value = String::from_utf8_lossy(text.as_ref());
|
||||
match field.as_slice() {
|
||||
b"ActionID" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
match part {
|
||||
Some(ProfilePart::Input) => current.input = axis_index(&value),
|
||||
Some(ProfilePart::Output) => current.output = axis_index(&value),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Enabled" => {
|
||||
if let Some(current) = axis.as_mut() {
|
||||
current.enabled = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Reversed" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.reversed = value.trim().eq_ignore_ascii_case("true");
|
||||
}
|
||||
}
|
||||
b"Scale" => {
|
||||
if part == Some(ProfilePart::Output)
|
||||
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
|
||||
&& let Some(current) = axis.as_mut()
|
||||
{
|
||||
current.scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
b"OverallScale" => {
|
||||
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
|
||||
mapping.overall_scale = scale.clamp(0.0, 10.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(end) => match end.local_name().as_ref() {
|
||||
b"Axis" => {
|
||||
if let Some(current) = axis.take()
|
||||
&& let (Some(input), Some(output)) = (current.input, current.output)
|
||||
{
|
||||
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
b"Input" | b"Output" => part = None,
|
||||
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
|
||||
_ => {}
|
||||
},
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
found.then_some(mapping)
|
||||
}
|
||||
|
||||
fn load_driver_mapping() -> Option<DriverMapping> {
|
||||
let profile = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg").join("solvecraft-KMJ.xml");
|
||||
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
|
||||
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
|
||||
return None;
|
||||
}
|
||||
let xml = std::fs::read(&profile).ok()?;
|
||||
parse_driver_mapping(&xml)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Reading {
|
||||
axes: Axes,
|
||||
@@ -107,6 +276,8 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -118,13 +289,21 @@ impl SpaceMouse {
|
||||
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 }
|
||||
Self { shared, last_frame: now, driver_mapping: load_driver_mapping().unwrap_or_default(), mapping_checked_at: now }
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_default();
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
@@ -141,7 +320,14 @@ impl SpaceMouse {
|
||||
}
|
||||
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());
|
||||
navigate(
|
||||
&mut app.cam,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
@@ -153,15 +339,21 @@ fn normalized(value: i16) -> f64 {
|
||||
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) {
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = 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);
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
@@ -171,6 +363,10 @@ fn navigate(cam: &mut Camera, axes: Axes, seconds: f64, height: f64, sketch: boo
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
@@ -192,15 +388,102 @@ mod tests {
|
||||
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);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
|
||||
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
|
||||
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
|
||||
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));
|
||||
body.names = crate::naming::NameCell::same(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))
|
||||
|
||||
@@ -217,8 +217,7 @@ impl Document {
|
||||
}
|
||||
// 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);
|
||||
let hits: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|x| crate::naming::strip_piece(x) == stem)).collect();
|
||||
let hits = crate::naming::faces_with_stem(b, n);
|
||||
if !hits.is_empty() {
|
||||
for i in hits {
|
||||
out.retain(|(j, _)| *j != i);
|
||||
|
||||
@@ -329,6 +329,7 @@ impl FeatureKind {
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. }
|
||||
| FeatureKind::MeshConvert { .. }
|
||||
| FeatureKind::BodyTransfer { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) fn evaluate(doc: &Document, st: &mut ModelState, bodies: &[String], f
|
||||
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));
|
||||
body.names = crate::naming::NameCell::same(old);
|
||||
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
|
||||
*slot = body;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ 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 {
|
||||
// An older design's name keeps its mark; what follows it is remapped as any name.
|
||||
if let Some(old) = crate::naming::legacy_name(name) {
|
||||
return format!("{}{}", crate::naming::LEGACY_MARK, remap_topology(old, ids, bodies));
|
||||
}
|
||||
// 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:")
|
||||
@@ -91,7 +95,9 @@ pub fn remap_topology(name: &str, ids: &BTreeMap<u64, u64>, bodies: &BTreeMap<St
|
||||
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'|'))) {
|
||||
// A feature token starts a name, or one inside it: after `@`, `:` or `|`, or in a
|
||||
// piece's list of the faces around it (`[`, `,`).
|
||||
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@' | b':' | b'|' | b'[' | 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'.'))
|
||||
@@ -356,9 +362,11 @@ impl crate::Feature {
|
||||
mapped_name(n, bodies);
|
||||
}
|
||||
}
|
||||
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
|
||||
mapped_name(body, bodies)
|
||||
}
|
||||
K::Split { body, .. }
|
||||
| K::SheetConvert { body, .. }
|
||||
| K::SurfaceExtend { body, .. }
|
||||
| K::SurfaceTrim { body, .. }
|
||||
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
|
||||
_ => {}
|
||||
}
|
||||
match &mut self.kind {
|
||||
|
||||
@@ -68,12 +68,12 @@ pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
|
||||
}
|
||||
format!("{:x}", hash.finalize())
|
||||
}
|
||||
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
|
||||
fn exact_region_signature(curves: &ExactSketchCurves, 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<_>>>()?;
|
||||
let mut ids = outline.segs.iter().map(|segment| curves.find(segment)).collect::<Option<Vec<_>>>()?;
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
Some(ids)
|
||||
@@ -112,6 +112,7 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_k
|
||||
}
|
||||
}
|
||||
}
|
||||
let curves = ExactSketchCurves::new(sketch);
|
||||
let mut counts = std::collections::BTreeMap::new();
|
||||
let mut bindings = Vec::with_capacity(bodies.len());
|
||||
for body in bodies {
|
||||
@@ -121,7 +122,7 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solvecraft_k
|
||||
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),
|
||||
_ => exact_region_signature(&curves, region),
|
||||
};
|
||||
cached = Some((region, signature.clone()));
|
||||
if let Some((outer, holes)) = &signature {
|
||||
@@ -307,36 +308,55 @@ pub(crate) fn flat_source_matches(certificate: &FaceCertificate, source: &solvec
|
||||
|
||||
/// 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;
|
||||
pub(crate) struct ExactSketchCurves<'a> {
|
||||
segments: Vec<(&'a str, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl<'a> ExactSketchCurves<'a> {
|
||||
/// Prepare only bounded analytic curves, once per certification pass. Point lookup
|
||||
/// and segment allocation must not be repeated for every generated face.
|
||||
pub(crate) fn new(sketch: &'a SolvedSketch) -> Self {
|
||||
let mut segments = Vec::new();
|
||||
if sketch.sketch.curves.len() > 2000 {
|
||||
return Self { segments };
|
||||
}
|
||||
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;
|
||||
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;
|
||||
}
|
||||
found = Some(curve.id.clone());
|
||||
let originals = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
|
||||
sketch
|
||||
.sketch
|
||||
.point(c)
|
||||
.map(|center| vec![Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }])
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
sketch.sketch.segs(index)
|
||||
};
|
||||
segments.push((curve.id.as_str(), originals));
|
||||
}
|
||||
Self { segments }
|
||||
}
|
||||
|
||||
pub(crate) fn find(&self, generated: &Seg2) -> Option<String> {
|
||||
let mut found = None;
|
||||
for (id, originals) in &self.segments {
|
||||
if originals.iter().any(|original| exact_segment_contains(original, generated)) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(*id);
|
||||
}
|
||||
}
|
||||
found.map(str::to_owned)
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
@@ -486,6 +506,73 @@ mod shell_tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn solved(mut sketch: solvecraft_sketch::Sketch) -> SolvedSketch {
|
||||
let report = solvecraft_sketch::solve(&mut sketch);
|
||||
SolvedSketch { feature: 1, name: "sources".into(), plane: solvecraft_geom::Plane::XY, sketch, report, profiles: Vec::new() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_sources_keep_exact_identity_and_reject_ambiguity() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("line")).unwrap();
|
||||
sketch.add_circle(Vec2::new(20., 0.), 2., None, Some("circle")).unwrap();
|
||||
let construction = sketch.add_line(Vec2::ZERO, Vec2::new(10., 0.), None, None, Some("construction")).unwrap();
|
||||
sketch.curves[construction].construction = true;
|
||||
let sketch = solved(sketch);
|
||||
let curves = ExactSketchCurves::new(&sketch);
|
||||
let partial = Seg2::Line { a: Vec2::new(8., 0.), b: Vec2::new(2., 0.) };
|
||||
assert_eq!(curves.find(&partial).as_deref(), Some("line"));
|
||||
assert_eq!(curves.find(&Seg2::Arc { center: Vec2::new(20., 0.), radius: 2., start: 1., sweep: -2. }).as_deref(), Some("circle"));
|
||||
assert_eq!(curves.find(&Seg2::Line { a: Vec2::ZERO, b: Vec2::new(11., 0.) }), None);
|
||||
let mut ambiguous = sketch.clone();
|
||||
ambiguous.sketch.add_line(Vec2::new(1., 0.), Vec2::new(9., 0.), None, None, Some("overlap")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&ambiguous).find(&partial), None);
|
||||
// A prepared pass borrows one immutable evaluated sketch, so an upstream edit
|
||||
// necessarily prepares fresh sources rather than retaining stale coordinates.
|
||||
let mut moved = sketch.clone();
|
||||
moved.sketch = solvecraft_sketch::Sketch::new();
|
||||
moved.sketch.add_line(Vec2::new(0., 1.), Vec2::new(10., 1.), None, None, Some("line")).unwrap();
|
||||
assert_eq!(ExactSketchCurves::new(&moved).find(&partial), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual optimized certification microbenchmark; no timing assertion"]
|
||||
fn benchmark_prepared_extrude_sources() {
|
||||
use solvecraft_geom::Vec2;
|
||||
let mut sketch = solvecraft_sketch::Sketch::new();
|
||||
for i in 0..1000 {
|
||||
let y = i as f64;
|
||||
sketch.add_line(Vec2::new(0., y), Vec2::new(10., y), None, None, None).unwrap();
|
||||
}
|
||||
let sketch = solved(sketch);
|
||||
let queries: Vec<_> = (0..1000).map(|i| Seg2::Line { a: Vec2::new(2., i as f64), b: Vec2::new(8., i as f64) }).collect();
|
||||
let start = std::time::Instant::now();
|
||||
let old: Vec<_> = queries
|
||||
.iter()
|
||||
.map(|generated| {
|
||||
let mut found = None;
|
||||
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
||||
if sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated)) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(curve.id.clone());
|
||||
}
|
||||
}
|
||||
found
|
||||
})
|
||||
.collect();
|
||||
let old_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
let start = std::time::Instant::now();
|
||||
let curves = ExactSketchCurves::new(&sketch);
|
||||
let new: Vec<_> = queries.iter().map(|query| curves.find(query)).collect();
|
||||
let new_ms = start.elapsed().as_secs_f64() * 1000.;
|
||||
assert_eq!(old, new);
|
||||
assert!(new.iter().all(Option::is_some));
|
||||
eprintln!("1000 analytic sources / 1000 face segments: repeated={old_ms:.3}ms prepared={new_ms:.3}ms (includes preparation)");
|
||||
}
|
||||
#[test]
|
||||
fn certificate_budgets_apply_before_saved_target_and_paint_decode() {
|
||||
for certificate in [
|
||||
|
||||
@@ -146,7 +146,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
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"));
|
||||
assert!(saved.contains(&crate::format::format_string(crate::format::FORMAT)));
|
||||
let loaded = crate::format::read(&saved).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&loaded);
|
||||
@@ -176,10 +176,11 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
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");
|
||||
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
|
||||
current["format"] = json!("solvecraft/12");
|
||||
assert_eq!(legacy, current, "identity migration must preserve every prior field");
|
||||
legacy["format"] = json!("solvecraft/14");
|
||||
crate::format::pieces_in_shell_order(&mut legacy["features"]);
|
||||
assert_eq!(legacy, current, "the migration preserves every prior field");
|
||||
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
}
|
||||
|
||||
@@ -253,8 +254,10 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
|
||||
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 captured = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
|
||||
assert_eq!(crate::naming::parent_piece(&captured), Some(stem.as_str()), "actual strip capture records a piece name");
|
||||
// What the builds before format 14 saved for that piece: its number by centroid order.
|
||||
let former = format!("{stem}#1");
|
||||
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();
|
||||
@@ -457,3 +460,124 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
use solvecraft_kernel::{BoolOp, FacePaint, Paint};
|
||||
use solvecraft_sketch::{LinkKind, LinkSource, Sketch};
|
||||
let mut doc = Document::new("public stepped tab case");
|
||||
doc.set_param("length", "40", Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", "2", Some("mm"), None).unwrap();
|
||||
for (name, kind) in [
|
||||
("Case", json!({"type":"box","corner":[0,0,0],"length":"length","width":"30","height":"20"})),
|
||||
("Tab", json!({"type":"box","corner":["length",10,12],"length":"8","width":"10","height":"8"})),
|
||||
] {
|
||||
let id = doc.add_feature(serde_json::from_value(kind).unwrap(), Some(name)).unwrap();
|
||||
doc.feature_mut(id).unwrap().body_names = vec![name.into()];
|
||||
}
|
||||
doc.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Tab".into()], operation: crate::Operation::Join, keep_tools: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let tool = doc
|
||||
.add_feature(
|
||||
serde_json::from_value(
|
||||
json!({"type":"box","corner":["wall","wall","wall"],"length":"length-2*wall","width":"30-2*wall","height":"22-wall"}),
|
||||
)
|
||||
.unwrap(),
|
||||
Some("Pocket tool"),
|
||||
)
|
||||
.unwrap();
|
||||
doc.feature_mut(tool).unwrap().body_names = vec!["Pocket tool".into()];
|
||||
let cut = doc
|
||||
.add_feature(
|
||||
FeatureKind::Combine { target: "Case".into(), tools: vec!["Pocket tool".into()], operation: crate::Operation::Cut, keep_tools: false },
|
||||
Some("Pocket"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results);
|
||||
let before = model.state_before(cut);
|
||||
// A real per-face paint control preserves the former slab construction;
|
||||
// no historical names or geometry are fabricated.
|
||||
let old_stock = before
|
||||
.body("Case")
|
||||
.unwrap()
|
||||
.body
|
||||
.clone()
|
||||
.with_paint(Some(Paint { opacity: 1., faces: vec![FacePaint { face: 0, color: [1., 0., 0.], opacity: 1. }] }));
|
||||
let old_cut = solvecraft_kernel::boolean(&old_stock, &before.body("Pocket tool").unwrap().body, BoolOp::Cut).unwrap().unwrap();
|
||||
let mut old_body = ModelBody::new("Case".into(), old_cut, cut);
|
||||
old_body.names = crate::naming::NameCell::new(Some(std::sync::Arc::new(crate::naming::NamingOrigin {
|
||||
feature: doc.feature(cut).unwrap().clone(),
|
||||
before,
|
||||
depth: 1,
|
||||
mats: vec![],
|
||||
})));
|
||||
let at = Vec3::new(0., 15., 1.);
|
||||
let former = crate::naming::face_name_at_body(&old_body, at).unwrap();
|
||||
assert_ne!(former, crate::naming::strip_piece(&former), "actual former bounded wall patch has a full piece name");
|
||||
let index = crate::naming::face_on_body(&old_body, &former, at).unwrap().0;
|
||||
let mut old_state = crate::ModelState::default();
|
||||
old_state.bodies.push(old_body);
|
||||
let plane = solvecraft_geom::Plane::YZ;
|
||||
let resolved = crate::project::resolve_face_indices(&old_state, &plane, LinkKind::Project, "Case", &[index]).unwrap();
|
||||
let current_state = model.state();
|
||||
let current = current_state.body("Case").unwrap();
|
||||
let stem = crate::naming::strip_piece(&former);
|
||||
let current_names = crate::naming::face_names(current);
|
||||
let merged = current_names.iter().position(|name| name == stem).unwrap();
|
||||
assert!(current.body.face_was_coalesced(merged), "new exact generated-wall construction supplies the loss-only witness");
|
||||
assert!(!current_names.contains(&former));
|
||||
let mut sketch = Sketch::new();
|
||||
sketch.add_link(LinkKind::Project, LinkSource::Face { body: "Case".into(), at }, &resolved.geom).unwrap();
|
||||
sketch.links[0].face_name = Some(former);
|
||||
let original = sketch.clone();
|
||||
let captured = original.points.iter().filter(|p| p.link.is_some()).map(|p| p.pos).collect::<Vec<_>>();
|
||||
assert_eq!(captured.len(), 4);
|
||||
assert!(captured.iter().all(|p| (p.x == 10. || p.x == 20.) && (p.y == 0. || p.y == 20.)));
|
||||
let sk = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch }, Some("Saved wall patch")).unwrap();
|
||||
let material = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Saved band stock"),
|
||||
)
|
||||
.unwrap();
|
||||
for (length, wall) in [(40., 2.), (50., 3.)] {
|
||||
doc.set_param("length", &length.to_string(), Some("mm"), None).unwrap();
|
||||
doc.set_param("wall", &wall.to_string(), Some("mm"), None).unwrap();
|
||||
model.evaluate(&doc);
|
||||
let reopened = crate::format::read(&doc.to_json()).unwrap();
|
||||
let mut cold = Model::new();
|
||||
cold.evaluate(&reopened);
|
||||
for current in [&model, &cold] {
|
||||
let result = current.results.iter().find(|r| r.id == sk).unwrap();
|
||||
assert!(result.error.is_none(), "{result:?}");
|
||||
assert!(result.warning.as_deref().is_some_and(|w| w.contains("no longer exists; last geometry retained")), "{result:?}");
|
||||
let state = current.state();
|
||||
let saved = state.sketch(sk).unwrap();
|
||||
assert!(saved.sketch.links[0].lost);
|
||||
assert_eq!(saved.sketch.points, original.points);
|
||||
assert_eq!(saved.sketch.curves, original.curves);
|
||||
let stock = state.bodies.iter().find(|b| b.feature == material).unwrap();
|
||||
assert!(
|
||||
(solvecraft_kernel::measure(&stock.body).unwrap().volume - 200.).abs() < 1e-7,
|
||||
"saved10x20 patch must not become30x20 wall; actualvolume={}, bounds={:?}, originalpoints={:?}",
|
||||
solvecraft_kernel::measure(&stock.body).unwrap().volume,
|
||||
stock.mesh().bounds(),
|
||||
original.points
|
||||
);
|
||||
let case = state.body("Case").unwrap();
|
||||
let expected = length * 30. * 20. + 8. * 10. * 8. - (length - 2. * wall) * (30. - 2. * wall) * (20. - wall);
|
||||
assert!((solvecraft_kernel::measure(&case.body).unwrap().volume - expected).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,14 @@ fn sketch_world(ctx: &Ctx, sketch: u64, q: Vec2) -> Result<Vec3> {
|
||||
|
||||
fn sketch_curve_points(ctx: &Ctx, sketch: u64, curve: &str) -> Result<Vec<Vec3>> {
|
||||
let ss = ctx.st.sketch(sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
if let Some(wire) = ss.sketch.wires.iter().find(|wire| wire.id == curve) {
|
||||
if wire.pts.len() < 2 {
|
||||
return Err(bad(format!("curve `{curve}` has no length")));
|
||||
}
|
||||
// Evaluation has already mapped wires into this component's frame. Their coordinates
|
||||
// are independent of the sketch plane; applying it again would move the path twice.
|
||||
return Ok(wire.pts.clone());
|
||||
}
|
||||
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("curve `{curve}`")))?;
|
||||
let pl = ss.sketch.polyline(ci);
|
||||
if pl.len() < 2 {
|
||||
|
||||
@@ -883,8 +883,12 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
components: Vec<solvecraft_kernel::StepNode>,
|
||||
},
|
||||
/// A non-parametric base feature: mesh bodies imported from a 3MF or STL file (kept in the
|
||||
/// design). Mesh bodies render, measure, move and export; solid features cannot use them.
|
||||
/// Rebuild an upstream constant-section mesh with analytic planar/cylindrical faces.
|
||||
MeshConvert {
|
||||
body: String,
|
||||
tolerance: String,
|
||||
},
|
||||
/// Mesh bodies imported from a 3MF or STL file, embedded in the design.
|
||||
MeshImport {
|
||||
/// Source file name (display only).
|
||||
file: String,
|
||||
@@ -1162,6 +1166,7 @@ impl FeatureKind {
|
||||
| K::Move { .. }
|
||||
| K::Import { .. }
|
||||
| K::MeshImport { .. }
|
||||
| K::MeshConvert { .. }
|
||||
| K::BodyTransfer { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1247,6 +1252,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
|
||||
FeatureKind::Import { .. } => "BaseFeature",
|
||||
FeatureKind::MeshImport { .. } => "MeshFeature",
|
||||
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
|
||||
}
|
||||
}
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
@@ -1312,6 +1318,7 @@ impl FeatureKind {
|
||||
FeatureKind::SurfaceExtend { .. } => "Extend",
|
||||
FeatureKind::Import { .. } => "Import",
|
||||
FeatureKind::MeshImport { .. } => "Mesh",
|
||||
FeatureKind::MeshConvert { .. } => "ConvertMesh",
|
||||
}
|
||||
}
|
||||
/// Every expression the feature uses.
|
||||
@@ -1520,6 +1527,7 @@ impl FeatureKind {
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
|
||||
FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } | FeatureKind::DeleteFace { .. } => {}
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
|
||||
FeatureKind::MeshConvert { tolerance, .. } => v.push(tolerance),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
||||
@@ -1559,6 +1567,11 @@ pub struct Feature {
|
||||
/// component's bodies.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub participants: Vec<String>,
|
||||
/// The pieces this feature splits a body into take its body names in the kernel's shell order,
|
||||
/// as builds before format 14 named them, instead of after their faces (`lineage::order_lumps`).
|
||||
/// Set on the features of designs saved before then, whose stored body names mean that order.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub shell_piece_order: bool,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
@@ -1893,6 +1906,7 @@ impl Document {
|
||||
edge_names: Vec::new(),
|
||||
face_names: Vec::new(),
|
||||
participants: Vec::new(),
|
||||
shell_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
match self.marker {
|
||||
|
||||
+428
-154
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
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) {
|
||||
if !crate::naming::face_has_name(&mb, index as usize, name) {
|
||||
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
|
||||
}
|
||||
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
|
||||
|
||||
+203
-4
@@ -39,13 +39,20 @@
|
||||
//! - 13: exact planar shell face anchors retain the shell producer, original certified
|
||||
//! source and distinct inner/outer role. Version 12 anchors retain their identities;
|
||||
//! migration never guesses attachments for older designs.
|
||||
//! - 14: persistent names follow the kernel's exact face history. Pieces of a split face are
|
||||
//! named by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`) instead of numbered by
|
||||
//! where their centres lay (`F2:top#2`). Names stored by older formats were worked out from
|
||||
//! geometry, and a few of them name a face that history names differently; they get the
|
||||
//! legacy mark (`?F2:top#2`) and are found again as the build that saved them found them.
|
||||
//! Pieces a feature splits a body into are named after their faces; features saved before
|
||||
//! keep the kernel's shell order (`shell_piece_order`), which their stored body names mean.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 13;
|
||||
pub const FORMAT: u32 = 14;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solvecraft/{n}")
|
||||
@@ -58,8 +65,129 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 12] =
|
||||
[|_| Ok(()), v2_to_v3, v3_to_v4, v4_to_v5, v5_to_v6, |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(()), |_| Ok(())];
|
||||
const JSON_STEPS: [JsonStep; 13] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
v4_to_v5,
|
||||
v5_to_v6,
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
v13_to_v14,
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
/// legacy mark (`?`, see `naming::LEGACY_MARK`) so they are found again as those builds found
|
||||
/// them, and an older design evaluates as it was saved. The step marks the fields that store
|
||||
/// such a name, by their place in the schema:
|
||||
/// - each feature's `face_names`, and fillets' and chamfers' `edge_names` (other features' edge
|
||||
/// names are sheet metal's own);
|
||||
/// - a sketch feature's links' `face_name`;
|
||||
/// - face and edge references (`PlaneRef::Face`, `GeoRef::Face`, `GeoRef::Edge`: tagged
|
||||
/// `"type": "face"|"edge"`, picked `at` a point), wherever a feature holds one;
|
||||
/// - joint snaps on a face (`face_center`, `circle_center`), in joints and named origins;
|
||||
/// - face appearances, but not strict ones, whose names are certified lineage.
|
||||
///
|
||||
/// Every feature also keeps naming the pieces it splits a body into in the kernel's shell order
|
||||
/// (`Feature::shell_piece_order`): the body names stored after it (bodies, targets, participants,
|
||||
/// materials, appearances, joints) mean the pieces in that order.
|
||||
fn v13_to_v14(v: &mut Value) -> Result<()> {
|
||||
if let Some(Value::Array(features)) = v.get_mut("features") {
|
||||
for f in features.iter_mut() {
|
||||
let Value::Object(o) = f else { continue };
|
||||
let kind = o.get("type").and_then(Value::as_str).map(str::to_string);
|
||||
if matches!(kind.as_deref(), Some("fillet" | "chamfer")) {
|
||||
mark_all(o.get_mut("edge_names"));
|
||||
}
|
||||
mark_all(o.get_mut("face_names"));
|
||||
if kind.as_deref() == Some("sketch")
|
||||
&& let Some(Value::Array(links)) = o.get_mut("sketch").and_then(|s| s.get_mut("links"))
|
||||
{
|
||||
links.iter_mut().filter_map(|l| l.get_mut("face_name")).for_each(mark);
|
||||
}
|
||||
o.insert("shell_piece_order".into(), Value::Bool(true));
|
||||
references(f);
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(joints)) = v.get_mut("assembly").and_then(|a| a.get_mut("joints")) {
|
||||
for j in joints.iter_mut() {
|
||||
for side in ["a", "b"] {
|
||||
snap(j.get_mut(side));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(Value::Array(origins)) = v.get_mut("assembly").and_then(|a| a.get_mut("origins")) {
|
||||
origins.iter_mut().for_each(|o| snap(o.get_mut("origin")));
|
||||
}
|
||||
// Face appearances carry the face's name without a type tag.
|
||||
if let Some(Value::Array(faces)) = v.get_mut("appearances").and_then(|a| a.get_mut("faces")) {
|
||||
for face in faces.iter_mut().filter(|f| f.get("strict_name").and_then(Value::as_bool) != Some(true)) {
|
||||
if let Some(n) = face.get_mut("name") {
|
||||
mark(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the `name` of every face or edge reference in `v` (see [`v13_to_v14`]).
|
||||
fn references(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
let reference = matches!(o.get("type").and_then(Value::as_str), Some("face" | "edge")) && o.contains_key("at");
|
||||
for (key, x) in o.iter_mut() {
|
||||
if reference && key == "name" {
|
||||
mark(x);
|
||||
} else {
|
||||
references(x);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(references),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the face name of a joint origin's snap on a face.
|
||||
fn snap(origin: Option<&mut Value>) {
|
||||
if let Some(snap) = origin.and_then(|o| o.get_mut("snap"))
|
||||
&& matches!(snap.get("type").and_then(Value::as_str), Some("face_center" | "circle_center"))
|
||||
&& let Some(n) = snap.get_mut("face")
|
||||
{
|
||||
mark(n);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every name in a list of stored names.
|
||||
fn mark_all(names: Option<&mut Value>) {
|
||||
if let Some(Value::Array(names)) = names {
|
||||
names.iter_mut().for_each(mark);
|
||||
}
|
||||
}
|
||||
|
||||
/// `features` (a design's, as JSON) as an upgrade from before format 14 leaves them: each keeps
|
||||
/// the shell order of the pieces it makes (see [`v13_to_v14`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn pieces_in_shell_order(features: &mut Value) {
|
||||
for f in features.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
|
||||
f.insert("shell_piece_order".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored name with the legacy mark (an empty name stays empty: no reference).
|
||||
fn mark(n: &mut Value) {
|
||||
if let Value::String(s) = n
|
||||
&& !s.is_empty()
|
||||
&& crate::naming::legacy_name(s).is_none()
|
||||
{
|
||||
s.insert(0, crate::naming::LEGACY_MARK);
|
||||
}
|
||||
}
|
||||
|
||||
/// Old XZ references retain their exact frame, including nested offset and angled planes.
|
||||
/// New origin references use +Y; freezing the old frame avoids changing sketch coordinates,
|
||||
@@ -596,6 +724,74 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() {
|
||||
let file = serde_json::json!({
|
||||
"format": "solvecraft/13",
|
||||
"features": [
|
||||
{"type": "fillet", "edge_names": ["F2:side:l2|F2:top#2~1", ""], "face_names": ["F2:top#1", ""]},
|
||||
{"type": "sheet_flange", "edge_names": ["base:l1:top"]},
|
||||
{"type": "sketch", "plane": {"type": "face", "plane": {}, "at": [0, 0, 0], "name": "F2:top#1"},
|
||||
"sketch": {"links": [{"id": "l1", "face_name": "F2:top#2"}]}},
|
||||
{"type": "construction_axis", "def": {"type": "two_points",
|
||||
"a": {"type": "edge", "at": [0, 0, 0], "name": "F2:start|F2:side:l1"},
|
||||
"b": {"type": "face", "at": [0, 0, 0], "name": "F2:start"}}},
|
||||
{"name": "Hole #2", "type": "extrude"},
|
||||
{"type": "thread", "face": [1, 2, 3]}
|
||||
],
|
||||
"assembly": {
|
||||
"joints": [{"a": {"occurrence": 0, "snap": {"type": "face_center", "pick": [0, 0, 0], "face": "F2:top#2"}},
|
||||
"b": {"occurrence": 1, "snap": {"type": "point", "point": [0, 0, 0]}}}],
|
||||
"origins": [{"name": "O1", "origin": {"occurrence": 0, "snap": {"type": "circle_center", "pick": [0, 0, 0], "face": "F3:side"}}}]
|
||||
},
|
||||
"appearances": {"faces": [{"body": "Body1", "name": "F2:top#1"}, {"body": "Body1", "name": "F2:+x", "strict_name": true}]}
|
||||
});
|
||||
let mut v = file.clone();
|
||||
v13_to_v14(&mut v).unwrap();
|
||||
assert_eq!(v["features"][0]["edge_names"], serde_json::json!(["?F2:side:l2|F2:top#2~1", ""]));
|
||||
assert_eq!(v["features"][0]["face_names"], serde_json::json!(["?F2:top#1", ""]));
|
||||
assert_eq!(v["features"][1]["edge_names"][0], "base:l1:top", "sheet metal's edge names are its own");
|
||||
assert_eq!(v["features"][2]["plane"]["name"], "?F2:top#1");
|
||||
assert_eq!(v["features"][2]["sketch"]["links"][0]["face_name"], "?F2:top#2");
|
||||
assert_eq!(v["features"][3]["def"]["a"]["name"], "?F2:start|F2:side:l1");
|
||||
assert_eq!(v["features"][3]["def"]["b"]["name"], "?F2:start");
|
||||
assert_eq!(v["features"][4]["name"], "Hole #2", "a feature's own name is not a face name");
|
||||
assert_eq!(v["features"][5]["face"], serde_json::json!([1, 2, 3]));
|
||||
assert_eq!(v["assembly"]["joints"][0]["a"]["snap"]["face"], "?F2:top#2");
|
||||
assert_eq!(v["assembly"]["origins"][0]["origin"]["snap"]["face"], "?F3:side");
|
||||
assert_eq!(v["appearances"]["faces"][0]["name"], "?F2:top#1");
|
||||
assert_eq!(v["appearances"]["faces"][1]["name"], "F2:+x", "strict appearance names are certified lineage");
|
||||
// Every feature keeps naming the pieces it splits a body into as the saving build did.
|
||||
assert!(v["features"].as_array().unwrap().iter().all(|f| f["shell_piece_order"] == true));
|
||||
// The step never marks twice.
|
||||
let once = v.clone();
|
||||
v13_to_v14(&mut v).unwrap();
|
||||
assert_eq!(v, once);
|
||||
}
|
||||
|
||||
/// Only the fields that store a face or edge name are marked: other fields with the same key
|
||||
/// names, and objects tagged `face` or `edge` that are not references, are left alone.
|
||||
#[test]
|
||||
fn version_thirteen_marking_targets_name_fields_not_key_names() {
|
||||
let file = serde_json::json!({
|
||||
"format": "solvecraft/13",
|
||||
"features": [
|
||||
{"type": "extrude", "options": {"face": "flat", "face_name": "label", "face_names": ["a"]}},
|
||||
{"type": "fillet", "settings": {"edge_names": ["kept"]}},
|
||||
{"type": "sketch", "sketch": {"style": {"face_name": "kept"}}},
|
||||
{"type": "split_body", "tool": {"type": "face", "name": "not a reference"}}
|
||||
],
|
||||
"assembly": {"joints": [{"a": {"occurrence": 0, "snap": {"type": "frame", "face": "kept"}}}]},
|
||||
"notes": {"face": "F2:top", "face_name": "F2:top", "face_names": ["F2:top"]}
|
||||
});
|
||||
let mut v = file.clone();
|
||||
v13_to_v14(&mut v).unwrap();
|
||||
for f in v["features"].as_array_mut().unwrap() {
|
||||
f.as_object_mut().unwrap().remove("shell_piece_order");
|
||||
}
|
||||
assert_eq!(v, file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_nine_wire_spaces_preserve_saved_points_and_explicit_markers() {
|
||||
use solvecraft_geom::Vec3;
|
||||
@@ -1006,6 +1202,7 @@ mod tests {
|
||||
legacy["format"] = Value::from("solvecraft/6");
|
||||
let original_table = legacy["configs"].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
|
||||
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
|
||||
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
|
||||
assert_eq!(upgraded.format, format_string(FORMAT));
|
||||
@@ -1057,9 +1254,11 @@ mod tests {
|
||||
d.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, Some("Dimensioned")).unwrap();
|
||||
let mut legacy: Value = serde_json::from_str(&d.to_json()).unwrap();
|
||||
legacy["format"] = Value::from("solvecraft/7");
|
||||
let features = legacy["features"].clone();
|
||||
let mut features = legacy["features"].clone();
|
||||
pieces_in_shell_order(&mut features);
|
||||
let params = legacy["params"].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
|
||||
assert_eq!(upgraded, d);
|
||||
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
|
||||
assert_eq!(upgraded_json["features"], features);
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Face names from exact history, and how a stored name matches a face.
|
||||
//!
|
||||
//! A face's name says what made it (see `naming` for the vocabulary). Where the feature that made
|
||||
//! a body reports its history (a [`Lineage`]), each face is named after the faces it came from:
|
||||
//! kept, trimmed or moved faces keep their names, a face made from a profile segment or cap is
|
||||
//! named by that role, a fillet face by the edge it replaces, a shell's inner wall by the face it
|
||||
//! offsets. Nothing is sampled. Where a face's origin cannot be named exactly the face is named
|
||||
//! from geometry, as a body without history is, and marked unverified.
|
||||
//!
|
||||
//! Two faces with the same name are pieces of one face (a cut split it). Each piece is told apart
|
||||
//! by the faces around it that the others do not have: `F2:top#[F2:side:l4,F4:side:l4]` is the
|
||||
//! piece of `F2:top` bounded by the block's left wall and the slot's left wall. Pieces nothing
|
||||
//! around them tells apart are numbered (`#1`, `#2`): in the order of what they came from when
|
||||
//! that differs (exact), else in the kernel's order (unverified). A face made of several (a
|
||||
//! coplanar join) takes one name and answers to the others (aliases).
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_kernel::history::{FaceOrigin, FaceProv, History};
|
||||
|
||||
use crate::ModelBody;
|
||||
use crate::lineage::{Lineage, Operand};
|
||||
use crate::naming::NamingOrigin;
|
||||
|
||||
/// The names of a body's faces, with what finding them again needs.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Names {
|
||||
pub faces: Arc<Vec<String>>,
|
||||
pub(crate) info: Vec<FaceInfo>,
|
||||
}
|
||||
|
||||
/// What a face answers to besides its name, and how sure its name is.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FaceInfo {
|
||||
/// Names of the other faces merged into it (a reference to one of them finds this face).
|
||||
pub aliases: Vec<String>,
|
||||
/// The stems of the faces around it: what tells it apart from other pieces of its face.
|
||||
pub bounds: BTreeSet<String>,
|
||||
/// Whether the name follows exact history (`false`: worked out from geometry, or a piece
|
||||
/// numbered because the faces around it did not tell it apart).
|
||||
pub verified: bool,
|
||||
}
|
||||
|
||||
impl Names {
|
||||
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
|
||||
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
|
||||
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
|
||||
let named = names
|
||||
.into_iter()
|
||||
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
|
||||
.collect();
|
||||
qualify(named, adjacent)
|
||||
}
|
||||
|
||||
/// Names given as they are (a moved copy keeps its source's), taken as exact.
|
||||
pub(crate) fn given(names: Arc<Vec<String>>) -> Names {
|
||||
let info = vec![FaceInfo { verified: true, ..FaceInfo::default() }; names.len()];
|
||||
Names { faces: names, info }
|
||||
}
|
||||
|
||||
/// Whether face `index`'s name follows exact history.
|
||||
pub fn verified(&self, index: usize) -> bool {
|
||||
self.info.get(index).is_some_and(|i| i.verified)
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's name before pieces are told apart.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Named {
|
||||
name: String,
|
||||
aliases: Vec<String>,
|
||||
verified: bool,
|
||||
/// What it came from (`None`: not known): faces with one name but different origins (an
|
||||
/// extrude's two caps both above its sketch plane) are numbered in this order.
|
||||
origins: Option<Vec<FaceOrigin>>,
|
||||
}
|
||||
|
||||
/// Names of a body (`b`, made by `o`'s feature) from its lineage; `None` when the lineage does
|
||||
/// not describe it (then it is named from geometry).
|
||||
pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Option<Names> {
|
||||
let h = &lineage.history;
|
||||
if h.faces.len() != b.body.face_count() || h.edges.len() != b.body.edge_count() {
|
||||
return None;
|
||||
}
|
||||
let operands: Vec<OperandNames> = lineage.operands.iter().map(|op| OperandNames::new(op, o)).collect::<Option<_>>()?;
|
||||
let fid = o.feature.id;
|
||||
let mut named: Vec<Option<Named>> = h.faces.iter().map(|f| from_origins(f, &operands, fid)).collect();
|
||||
if named.iter().any(Option::is_none) {
|
||||
let geometric = crate::naming::geometric_names(b, o, named.len());
|
||||
for (slot, name) in named.iter_mut().zip(geometric) {
|
||||
if slot.is_none() {
|
||||
*slot = Some(Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
|
||||
Some(qualify(named, &adjacency(h)))
|
||||
}
|
||||
|
||||
/// Pairs of faces that share an edge, from a history.
|
||||
fn adjacency(h: &History) -> Vec<(usize, usize)> {
|
||||
face_pairs(h.edges.iter().map(|e| e.faces.iter().copied()))
|
||||
}
|
||||
|
||||
/// The two faces of each edge (edges given by their faces) that has exactly two.
|
||||
pub(crate) fn face_pairs<I: Iterator<Item = usize>>(edges: impl Iterator<Item = I>) -> Vec<(usize, usize)> {
|
||||
edges
|
||||
.filter_map(|mut faces| match (faces.next(), faces.next(), faces.next()) {
|
||||
(Some(a), Some(b), None) => Some((a, b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// An operand's faces' names.
|
||||
struct OperandNames<'a> {
|
||||
operand: &'a Operand,
|
||||
/// The model body (or a stand-in for a stage) the operand's names belong to.
|
||||
body: Option<ModelBody>,
|
||||
names: Arc<Names>,
|
||||
/// Its edges' names, worked out on first use (for the faces that blend them).
|
||||
edges: OnceLock<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> OperandNames<'a> {
|
||||
fn new(operand: &'a Operand, o: &NamingOrigin) -> Option<OperandNames<'a>> {
|
||||
match operand {
|
||||
Operand::Body(name) | Operand::Copy { body: name, .. } | Operand::Inner(name) => {
|
||||
let body = o.before.body(name)?;
|
||||
Some(OperandNames { operand, names: crate::naming::names(body), body: Some(body.clone()), edges: OnceLock::new() })
|
||||
}
|
||||
Operand::Made(_) | Operand::CopiedTool { .. } => {
|
||||
Some(OperandNames { operand, body: None, names: Arc::default(), edges: OnceLock::new() })
|
||||
}
|
||||
Operand::Stage(stage) => {
|
||||
let body = &stage.body;
|
||||
let names = stage
|
||||
.names
|
||||
.get_or_init(|| {
|
||||
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
|
||||
let names = crate::naming::geometric_names(body, o, body.body.face_count());
|
||||
Names::unverified(names, &crate::naming::mesh_adjacency(body))
|
||||
}))
|
||||
})
|
||||
.clone();
|
||||
Some(OperandNames { operand, body: Some(body.clone()), names, edges: OnceLock::new() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of face `face`.
|
||||
fn face(&self, face: usize, fid: u64) -> Option<Named> {
|
||||
let given = |names: &Names| {
|
||||
let info = names.info.get(face)?;
|
||||
Some(Named { name: names.faces.get(face)?.clone(), aliases: info.aliases.clone(), verified: info.verified, origins: None })
|
||||
};
|
||||
match self.operand {
|
||||
Operand::Body(_) | Operand::Stage(_) => given(&self.names),
|
||||
Operand::Copy { instance, .. } => given(&self.names).map(|n| Named {
|
||||
name: format!("{}@F{fid}.{instance}", n.name),
|
||||
aliases: n.aliases.iter().map(|a| format!("{a}@F{fid}.{instance}")).collect(),
|
||||
verified: n.verified,
|
||||
origins: None,
|
||||
}),
|
||||
Operand::Inner(_) => given(&self.names).map(|n| Named {
|
||||
name: format!("F{fid}:inner:{}", n.name),
|
||||
aliases: Vec::new(),
|
||||
verified: n.verified,
|
||||
origins: None,
|
||||
}),
|
||||
Operand::Made(roles) => {
|
||||
let (first, rest) = roles.get(face)?.split_first()?;
|
||||
Some(Named {
|
||||
name: format!("F{fid}:{first}"),
|
||||
aliases: rest.iter().map(|r| format!("F{fid}:{r}")).collect(),
|
||||
verified: true,
|
||||
origins: None,
|
||||
})
|
||||
}
|
||||
Operand::CopiedTool { roles, feature, instance } => {
|
||||
let named = |role: &String| format!("F{feature}:{role}@F{fid}.{instance}");
|
||||
let (first, rest) = roles.get(face)?.split_first()?;
|
||||
Some(Named { name: named(first), aliases: rest.iter().map(named).collect(), verified: true, origins: None })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of edge `edge`, and whether it is exact.
|
||||
fn edge(&self, edge: usize) -> Option<(String, bool)> {
|
||||
match self.operand {
|
||||
Operand::Body(_) | Operand::Stage(_) => {
|
||||
let body = self.body.as_ref()?;
|
||||
let names = self.edges.get_or_init(|| crate::naming::edge_names(body));
|
||||
let faces = body.mesh().edge_faces.get(edge).cloned().unwrap_or_default();
|
||||
let verified = faces.iter().all(|f| self.names.verified(*f as usize));
|
||||
Some((names.get(edge)?.clone(), verified))
|
||||
}
|
||||
Operand::Copy { .. } | Operand::Inner(_) | Operand::Made(_) | Operand::CopiedTool { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's name from what it came from; `None` when an origin cannot be named exactly.
|
||||
fn from_origins(face: &FaceProv, operands: &[OperandNames], fid: u64) -> Option<Named> {
|
||||
let mut origins: Vec<FaceOrigin> = face.origins.clone();
|
||||
origins.sort_unstable();
|
||||
let mut names: Vec<Named> = Vec::with_capacity(origins.len());
|
||||
for origin in origins.iter().copied() {
|
||||
let named = match origin {
|
||||
FaceOrigin::Face { operand, face } => operands.get(operand)?.face(face, fid)?,
|
||||
FaceOrigin::Edge { operand, edge } => {
|
||||
let (edge, verified) = operands.get(operand)?.edge(edge)?;
|
||||
Named { name: format!("F{fid}:blend:{edge}"), aliases: Vec::new(), verified, origins: None }
|
||||
}
|
||||
FaceOrigin::Offset { operand, face } => {
|
||||
let source = operands.get(operand)?.face(face, fid)?;
|
||||
Named { name: format!("F{fid}:inner:{}", source.name), aliases: Vec::new(), verified: source.verified, origins: None }
|
||||
}
|
||||
// A profile or cap of a body made from nothing: only a built body's own roles name
|
||||
// these, and those come in as `Made` operands.
|
||||
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => return None,
|
||||
};
|
||||
if !names.iter().any(|n| n.name == named.name) {
|
||||
names.push(named);
|
||||
}
|
||||
}
|
||||
merged(names).map(|n| Named { origins: Some(origins), ..n })
|
||||
}
|
||||
|
||||
/// One name for a face made of faces with these names: the face they are all pieces of, else the
|
||||
/// first (the target's before a tool's); the others become aliases.
|
||||
fn merged(names: Vec<Named>) -> Option<Named> {
|
||||
let verified = names.iter().all(|n| n.verified);
|
||||
let first = names.first()?.name.clone();
|
||||
let mut common = Some(first.as_str());
|
||||
while let Some(c) = common {
|
||||
if names.iter().all(|n| descends(&n.name, c)) {
|
||||
break;
|
||||
}
|
||||
common = crate::naming::parent_piece(c);
|
||||
}
|
||||
let name = common.map(str::to_string).unwrap_or(first);
|
||||
let mut aliases: Vec<String> = Vec::new();
|
||||
for n in names {
|
||||
for alias in std::iter::once(n.name).chain(n.aliases) {
|
||||
if alias != name && !aliases.contains(&alias) {
|
||||
aliases.push(alias);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Named { name, aliases, verified, origins: None })
|
||||
}
|
||||
|
||||
/// Whether `name` is `stem` or a piece of it (at any depth).
|
||||
pub(crate) fn descends(name: &str, stem: &str) -> bool {
|
||||
let mut current = Some(name);
|
||||
while let Some(n) = current {
|
||||
if n == stem {
|
||||
return true;
|
||||
}
|
||||
current = crate::piece_names::parent_piece(n);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
|
||||
/// lack (`adjacent`: pairs of faces sharing an edge).
|
||||
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
let n = named.len();
|
||||
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
|
||||
for &(a, b) in adjacent {
|
||||
let (Some(na), Some(nb)) = (named.get(a), named.get(b)) else { continue };
|
||||
let (sa, sb) = (crate::naming::strip_piece(&na.name), crate::naming::strip_piece(&nb.name));
|
||||
if a == b || sa == sb {
|
||||
continue;
|
||||
}
|
||||
if let Some(set) = bounds.get_mut(a) {
|
||||
set.insert(sb.to_string());
|
||||
}
|
||||
if let Some(set) = bounds.get_mut(b) {
|
||||
set.insert(sa.to_string());
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
|
||||
for (i, x) in named.iter().enumerate() {
|
||||
groups.entry(x.name.as_str()).or_default().push(i);
|
||||
}
|
||||
let mut names: Vec<String> = named.iter().map(|x| x.name.clone()).collect();
|
||||
let mut verified: Vec<bool> = named.iter().map(|x| x.verified).collect();
|
||||
for (base, members) in groups.iter().filter(|(_, m)| m.len() > 1) {
|
||||
let empty = BTreeSet::new();
|
||||
let common: BTreeSet<&String> = members
|
||||
.iter()
|
||||
.map(|i| bounds.get(*i).unwrap_or(&empty))
|
||||
.fold(None::<BTreeSet<&String>>, |acc, set| {
|
||||
Some(match acc {
|
||||
None => set.iter().collect(),
|
||||
Some(acc) => acc.into_iter().filter(|x| set.contains(*x)).collect(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let own = |i: usize| -> Vec<&String> { bounds.get(i).unwrap_or(&empty).iter().filter(|x| !common.contains(x)).collect() };
|
||||
let mut by_key: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for &i in members {
|
||||
let key: Vec<&str> = own(i).into_iter().map(String::as_str).collect();
|
||||
// Nothing around it of its own: the piece is only numbered (below).
|
||||
let name = if key.is_empty() { base.to_string() } else { format!("{base}#[{}]", key.join(",")) };
|
||||
by_key.entry(name).or_default().push(i);
|
||||
}
|
||||
for (key, tied) in by_key {
|
||||
if let [i] = tied.as_slice() {
|
||||
if let Some(slot) = names.get_mut(*i) {
|
||||
*slot = key;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The faces around them don't tell these apart: numbered by what they came from when
|
||||
// that differs (exact), else in the kernel's order (unverified).
|
||||
let mut tied = tied;
|
||||
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
|
||||
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
|
||||
if distinct {
|
||||
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
|
||||
}
|
||||
for (k, i) in tied.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*i) {
|
||||
*slot = format!("{key}#{}", k + 1);
|
||||
}
|
||||
if let Some(v) = verified.get_mut(*i).filter(|_| !distinct) {
|
||||
*v = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A piece's new name can meet a face that already had it: numbered too, never shared.
|
||||
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in &names {
|
||||
*seen.entry(name.clone()).or_default() += 1;
|
||||
}
|
||||
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (i, name) in names.iter_mut().enumerate() {
|
||||
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
|
||||
let k = counter.entry(name.clone()).or_default();
|
||||
*k += 1;
|
||||
*name = format!("{name}#{k}");
|
||||
if let Some(v) = verified.get_mut(i) {
|
||||
*v = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
let info = named.into_iter().zip(bounds).zip(verified).map(|((x, bounds), verified)| FaceInfo { aliases: x.aliases, bounds, verified }).collect();
|
||||
Names { faces: Arc::new(names), info }
|
||||
}
|
||||
|
||||
/// How a stored face name matches a current face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Match {
|
||||
/// Only its root stem is the same: some piece of the same face (the caller picks by point).
|
||||
Stem,
|
||||
/// A piece of the named face, or the named piece told apart by the faces around it.
|
||||
Piece,
|
||||
/// One of the faces merged into this one.
|
||||
Alias,
|
||||
/// The very name.
|
||||
Exact,
|
||||
}
|
||||
|
||||
/// How the stored name `name` matches face `index` (`None`: not at all).
|
||||
pub(crate) fn matches(names: &Names, index: usize, name: &str) -> Option<Match> {
|
||||
let current = names.faces.get(index)?;
|
||||
let info = names.info.get(index);
|
||||
if current == name {
|
||||
return Some(Match::Exact);
|
||||
}
|
||||
if info.is_some_and(|i| i.aliases.iter().any(|a| a == name)) {
|
||||
return Some(Match::Alias);
|
||||
}
|
||||
// The named face (or piece) was split further: this is one of its pieces.
|
||||
if descends(current, name) {
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
// The named piece, told apart by the faces around it: they still bound this piece.
|
||||
if let Some((stem, around)) = crate::piece_names::piece_parts(name)
|
||||
&& crate::naming::parent_piece(current) == Some(stem)
|
||||
&& info.is_some_and(|i| around.iter().all(|f| i.bounds.contains(*f)))
|
||||
{
|
||||
return Some(Match::Piece);
|
||||
}
|
||||
let root = crate::naming::strip_piece(name);
|
||||
(crate::naming::strip_piece(current) == root).then_some(Match::Stem)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_names_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn piece_names_parse_into_stem_and_bounding_faces() {
|
||||
use crate::naming::{parent_piece, strip_edge_piece, strip_piece};
|
||||
use crate::piece_names::piece_parts;
|
||||
assert_eq!(strip_piece("F2:top#[F2:side:l4,F4:side:l4]"), "F2:top");
|
||||
assert_eq!(strip_piece("F2:top#[a]#[b]"), "F2:top");
|
||||
assert_eq!(parent_piece("F2:top#[a]#[b]"), Some("F2:top#[a]"));
|
||||
assert_eq!(strip_piece("F2:top#[a]#2"), "F2:top");
|
||||
assert_eq!(strip_piece("F2:top#2"), "F2:top", "an older design's numbered piece");
|
||||
assert_eq!(strip_piece("F2:top"), "F2:top");
|
||||
assert_eq!(parent_piece("F2:top"), None);
|
||||
assert_eq!(piece_parts("F2:top#[F2:side:l4,F5:blend:A#[x,y]|B]"), Some(("F2:top", vec!["F2:side:l4", "F5:blend:A#[x,y]|B"])));
|
||||
assert_eq!(piece_parts("F2:top#[]"), Some(("F2:top", vec![])));
|
||||
assert_eq!(strip_edge_piece("F2:side:l2|F2:top#[F4:side:l2]~2"), "F2:side:l2|F2:top");
|
||||
assert_eq!(strip_edge_piece("F2:side:l2#1|F2:top#2"), "F2:side:l2|F2:top");
|
||||
// Hostile names never panic and are their own stems.
|
||||
for n in ["", "#", "#[", "]", "a#[", "a]", "#[]", "a#[b", "x#[[]]]", "é#[ü]", "#1", "a#[b]#", "~", "a~"] {
|
||||
let _ = (strip_piece(n), parent_piece(n), piece_parts(n), strip_edge_piece(n));
|
||||
}
|
||||
assert_eq!(strip_piece("#[]"), "#[]");
|
||||
}
|
||||
|
||||
fn named(names: &[&str]) -> Vec<Named> {
|
||||
names.iter().map(|n| Named { name: n.to_string(), aliases: Vec::new(), verified: true, origins: None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_are_told_apart_by_the_faces_only_they_touch() {
|
||||
// A top face cut in two by a slot: each piece touches the front and back walls (common),
|
||||
// its own end wall and its own slot wall.
|
||||
let faces = named(&["top", "top", "front", "back", "left", "right", "slot:a", "slot:b"]);
|
||||
let adjacent = [(0, 2), (0, 3), (0, 4), (0, 6), (1, 2), (1, 3), (1, 5), (1, 7)];
|
||||
let names = qualify(faces, &adjacent);
|
||||
assert_eq!(names.faces[0], "top#[left,slot:a]");
|
||||
assert_eq!(names.faces[1], "top#[right,slot:b]");
|
||||
assert!(names.verified(0) && names.verified(1));
|
||||
// A stored name finds its piece, though the faces around it have changed since.
|
||||
assert_eq!(matches(&names, 0, "top#[left]"), Some(Match::Piece));
|
||||
assert_eq!(matches(&names, 1, "top#[left]"), Some(Match::Stem));
|
||||
assert_eq!(matches(&names, 0, "top"), Some(Match::Piece), "a piece of the named face");
|
||||
assert_eq!(matches(&names, 0, "top#[left,slot:a]"), Some(Match::Exact));
|
||||
assert_eq!(matches(&names, 2, "top"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_nothing_tells_apart_are_numbered_and_unverified() {
|
||||
let faces = named(&["top", "top", "ring"]);
|
||||
let names = qualify(faces, &[(0, 2), (1, 2)]);
|
||||
assert_eq!(names.faces[0], "top#1");
|
||||
assert_eq!(names.faces[1], "top#2");
|
||||
assert!(!names.verified(0) && !names.verified(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merged_face_takes_the_shared_stem_or_the_first_name_and_answers_to_the_others() {
|
||||
let m = merged(named(&["top#[a]", "top#[b]"])).unwrap();
|
||||
assert_eq!(m.name, "top");
|
||||
assert_eq!(m.aliases, vec!["top#[a]".to_string(), "top#[b]".to_string()]);
|
||||
let m = merged(named(&["top#1", "top#2"])).unwrap();
|
||||
assert_eq!(m.name, "top", "numbered pieces joined again");
|
||||
let m = merged(named(&["F2:top", "F9:top"])).unwrap();
|
||||
assert_eq!(m.name, "F2:top");
|
||||
assert_eq!(m.aliases, vec!["F9:top".to_string()]);
|
||||
let names = qualify(vec![m], &[]);
|
||||
assert_eq!(matches(&names, 0, "F9:top"), Some(Match::Alias));
|
||||
}
|
||||
@@ -22,11 +22,15 @@ pub mod face_anchor;
|
||||
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
|
||||
pub mod format;
|
||||
mod format_offsets;
|
||||
mod history_names;
|
||||
pub use format_offsets::whole_profile_selection_from_value;
|
||||
pub mod joints;
|
||||
mod lineage;
|
||||
pub mod motion;
|
||||
mod name_lookup;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
mod piece_names;
|
||||
mod profile_refs;
|
||||
pub use profile_refs::{capture_profile_selection, profile_selection_indices};
|
||||
pub mod plastic;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! What each face of a body a feature made came from, in the model's terms.
|
||||
//!
|
||||
//! The kernel reports, for every operation, which faces and edges of its operands each face and
|
||||
//! edge of its result came from ([`History`]). A feature may run several operations (a tool
|
||||
//! built from a sketch, then cut from each target, then split into lumps) whose operands are
|
||||
//! the feature's tools, intermediate bodies or bodies of the model before it. A [`Lineage`]
|
||||
//! composes those histories into one over the operands that names can be read from: bodies of
|
||||
//! the model before the feature (whose faces already have names), moved copies of them, and
|
||||
//! bodies the feature built itself (whose faces are named by their role: the sketch curve they
|
||||
//! sweep, the cap they are). Naming (`history_names`) reads it; nothing here samples geometry.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_geom::Region2;
|
||||
use solvecraft_kernel::Body;
|
||||
use solvecraft_kernel::history::{Change, FaceOrigin, FaceProv, History};
|
||||
|
||||
use crate::SolvedSketch;
|
||||
|
||||
/// Something a body's faces came from, and how its faces are named.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Operand {
|
||||
/// A body of the model before the feature (by name): its faces keep their names.
|
||||
Body(String),
|
||||
/// A moved copy of a body of the model before the feature, instance `instance` of a pattern
|
||||
/// or mirror: its faces are named after their sources (`<name>@F<feature>.<instance>`).
|
||||
Copy { body: String, instance: usize },
|
||||
/// The cavity side of a shell grown from a body's faces (by name): each face is the inner
|
||||
/// wall of the face it offsets (`F<feature>:inner:<name>`).
|
||||
Inner(String),
|
||||
/// A body the feature built: each face's role (`side:<curve>`, `top`, `+x`, …; the first is
|
||||
/// its name, the others aliases), empty where the role is not known exactly.
|
||||
Made(Vec<Vec<String>>),
|
||||
/// A moved copy of a body another feature (`feature`) builds, instance `instance` of a
|
||||
/// pattern or mirror of that feature: its faces named by their roles in that feature
|
||||
/// (`F<feature>:<role>@F<pattern>.<instance>`).
|
||||
CopiedTool { roles: Vec<Vec<String>>, feature: u64, instance: usize },
|
||||
/// A body the feature made on the way to its result (a cut body before it falls into lumps),
|
||||
/// named as a whole first so pieces of one face in different results are told apart.
|
||||
Stage(Box<Stage>),
|
||||
}
|
||||
|
||||
/// An intermediate body of a feature, with its lineage and, once worked out, its names.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Stage {
|
||||
/// The whole body (a stand-in model body: its mesh is worked out once for every lump).
|
||||
pub body: crate::ModelBody,
|
||||
pub lineage: Lineage,
|
||||
pub names: OnceLock<Arc<crate::history_names::Names>>,
|
||||
}
|
||||
|
||||
/// The history of a body over operands that can be named.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Lineage {
|
||||
pub(crate) history: History,
|
||||
pub(crate) operands: Vec<Arc<Operand>>,
|
||||
}
|
||||
|
||||
impl Lineage {
|
||||
/// `body` as it is: every face and edge its own, named as `operand` names them.
|
||||
pub(crate) fn of(body: &Body, operand: Operand) -> Lineage {
|
||||
Lineage { history: History::identity(body), operands: vec![Arc::new(operand)] }
|
||||
}
|
||||
|
||||
/// The lineage of `result`, made by one kernel operation from operands whose lineages are
|
||||
/// `inputs` (in the operation's operand order). `None` where the operation reports no
|
||||
/// history or its history cannot be composed with theirs: the result is then named from
|
||||
/// geometry, flagged as unverified.
|
||||
pub(crate) fn step(result: &Body, inputs: &[&Lineage]) -> Option<Lineage> {
|
||||
let mut history = result.history()?.clone();
|
||||
if history.operands != inputs.len() {
|
||||
return None;
|
||||
}
|
||||
// From the last operand back: each one's operands come in at its place.
|
||||
for (k, input) in inputs.iter().enumerate().rev() {
|
||||
if let Err(e) = history.substitute(k, &input.history) {
|
||||
log::debug!("no lineage: {e}");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let operands: Vec<Arc<Operand>> = inputs.iter().flat_map(|l| l.operands.iter().cloned()).collect();
|
||||
(history.operands == operands.len()).then_some(Lineage { history, operands })
|
||||
}
|
||||
|
||||
/// This lineage for a moved copy of what feature `feature` built (instance `instance` of a
|
||||
/// pattern or mirror of it): the faces it built are named as that feature's, copied.
|
||||
pub(crate) fn copied(&self, feature: u64, instance: usize) -> Lineage {
|
||||
let operands = self
|
||||
.operands
|
||||
.iter()
|
||||
.map(|op| match op.as_ref() {
|
||||
Operand::Made(roles) => Arc::new(Operand::CopiedTool { roles: roles.clone(), feature, instance }),
|
||||
_ => op.clone(),
|
||||
})
|
||||
.collect();
|
||||
Lineage { history: self.history.clone(), operands }
|
||||
}
|
||||
|
||||
/// A tool of `faces` faces and no edges (a mesh's triangles) whose faces have no role.
|
||||
pub(crate) fn unnamed_faces(faces: usize) -> Lineage {
|
||||
let faces = (0..faces).map(|face| FaceProv { origins: vec![FaceOrigin::Face { operand: 0, face }], change: Change::Kept }).collect();
|
||||
Lineage { history: History { operands: 1, faces, edges: Vec::new() }, operands: vec![Arc::new(Operand::Made(Vec::new()))] }
|
||||
}
|
||||
|
||||
/// The lineages of `pieces`, the lumps of `whole` (whose lineage is `lineage`): their faces
|
||||
/// named after the whole's, worked out once for the whole, so pieces of one face that end up
|
||||
/// in different bodies are told apart. `None` for a piece whose history is not known.
|
||||
/// A body in one piece is its own lump: its lineage as it is.
|
||||
pub(crate) fn lumps(whole: &Body, lineage: Option<&Lineage>, pieces: &[Body]) -> Vec<Option<Lineage>> {
|
||||
let Some(lineage) = lineage else { return vec![None; pieces.len()] };
|
||||
if pieces.len() < 2 {
|
||||
return vec![Some(lineage.clone()); pieces.len()];
|
||||
}
|
||||
let body = crate::ModelBody::new(String::new(), whole.clone(), 0);
|
||||
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: lineage.clone(), names: OnceLock::new() })));
|
||||
pieces
|
||||
.iter()
|
||||
.map(|piece| {
|
||||
let history = piece.history()?.clone();
|
||||
(history.operands == 1).then(|| Lineage { history, operands: vec![stage.clone()] })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A body made during a feature, with its lineage (`None`: not known; it is then named from
|
||||
/// geometry).
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Traced {
|
||||
pub body: Body,
|
||||
pub lineage: Option<Lineage>,
|
||||
}
|
||||
|
||||
impl Traced {
|
||||
/// A body the feature built whose faces' roles are not known: its faces are named from
|
||||
/// geometry, those of the bodies it is combined with by their history.
|
||||
pub fn unnamed(body: Body) -> Traced {
|
||||
let roles = vec![Vec::new(); body.face_count()];
|
||||
Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body }
|
||||
}
|
||||
|
||||
/// A body the feature built, its faces' roles given (one per face), else not known.
|
||||
pub fn made(body: Body, roles: Option<Vec<Vec<String>>>) -> Traced {
|
||||
match roles.filter(|r| r.len() == body.face_count()) {
|
||||
Some(roles) => Traced { lineage: Some(Lineage::of(&body, Operand::Made(roles))), body },
|
||||
None => Traced::unnamed(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// `copy`, this tool moved: instance `instance` of a pattern or mirror of feature `feature`,
|
||||
/// which built it.
|
||||
pub fn copied(&self, copy: Body, feature: u64, instance: usize) -> Traced {
|
||||
let lineage = self.lineage.as_ref().and_then(|l| Lineage::step(©, &[&l.copied(feature, instance)]));
|
||||
Traced { body: copy, lineage }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from `inputs` (in its operand order).
|
||||
pub fn step(result: Body, inputs: &[&Traced]) -> Traced {
|
||||
let lineages: Option<Vec<&Lineage>> = inputs.iter().map(|t| t.lineage.as_ref()).collect();
|
||||
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
|
||||
/// face is called, and each cap.
|
||||
pub(crate) struct ProfileRoles<'a> {
|
||||
/// The sketch the profile is in (its curves name the side faces), if any.
|
||||
pub sketch: Option<&'a SolvedSketch>,
|
||||
/// The region the body was built from, in the sketch's coordinates; its segments (outer loop,
|
||||
/// then holes) number [`FaceOrigin::Profile`].
|
||||
pub region: Option<&'a Region2>,
|
||||
/// The start and end caps' roles (`None`: not known).
|
||||
pub caps: [Option<String>; 2],
|
||||
}
|
||||
|
||||
impl<'a> ProfileRoles<'a> {
|
||||
/// The roles of the faces of a body built from the profile, whose history (over no
|
||||
/// operands: it was built, not changed) is `history`. A face swept from profile segments is
|
||||
/// `side:<curve>` of the sketch curve each lies on exactly; a cap is its cap's role. Unknown
|
||||
/// (empty) where a segment lies on no single sketch curve, or the history is not a builder's.
|
||||
pub fn of(&self, history: &History) -> Option<Vec<Vec<String>>> {
|
||||
if history.operands != 0 {
|
||||
return None;
|
||||
}
|
||||
let segments: Vec<&solvecraft_geom::Seg2> =
|
||||
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
|
||||
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let roles = history
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| {
|
||||
let mut roles: Vec<String> = Vec::new();
|
||||
for origin in &face.origins {
|
||||
let role = match *origin {
|
||||
FaceOrigin::Profile { segment } => curve(segment),
|
||||
FaceOrigin::Cap { end } => self.caps.get(usize::from(end)).cloned().flatten(),
|
||||
_ => None,
|
||||
};
|
||||
match role {
|
||||
Some(role) if !roles.contains(&role) => roles.push(role),
|
||||
Some(_) => {}
|
||||
// One unknown origin leaves the face unknown: never a partial guess.
|
||||
None => return Vec::new(),
|
||||
}
|
||||
}
|
||||
roles
|
||||
})
|
||||
.collect();
|
||||
Some(roles)
|
||||
}
|
||||
|
||||
/// A body swept from a profile (revolve, sweep, loft): its sides named by the sketch curves,
|
||||
/// its caps `start` and `end`.
|
||||
pub fn swept(sketch: Option<&'a SolvedSketch>, region: Option<&'a Region2>) -> ProfileRoles<'a> {
|
||||
ProfileRoles { sketch, region, caps: [Some("start".into()), Some("end".into())] }
|
||||
}
|
||||
|
||||
/// A tool built from the profile (and then moved, from `built`, when given), its faces named
|
||||
/// by their roles where its history gives them.
|
||||
pub fn tool(&self, body: Body, built: Option<&Body>) -> Traced {
|
||||
let history = match built {
|
||||
Some(built) => built_history(&body, built),
|
||||
None => body.history().cloned(),
|
||||
};
|
||||
Traced::made(body, history.and_then(|h| self.of(&h)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
|
||||
/// records them.
|
||||
pub(crate) fn box_tool(body: Body) -> Traced {
|
||||
let roles: Option<Vec<Vec<String>>> = body.face_sources().map(|sources| {
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| match source {
|
||||
Some(solvecraft_kernel::FaceSource::Box { axis, high, .. }) => {
|
||||
["x", "y", "z"].get(usize::from(*axis)).map(|a| vec![format!("{}{a}", if *high { "+" } else { "-" })]).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// A cylinder primitive: its wall `side`, its caps `bottom` (at the base) and `top`.
|
||||
pub(crate) fn cylinder_tool(body: Body) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[FaceOrigin::Cap { end }] => vec![if *end { "top" } else { "bottom" }.to_string()],
|
||||
origins if !origins.is_empty() && origins.iter().all(|o| matches!(o, FaceOrigin::Profile { .. })) => vec!["side".to_string()],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// Faces named by their place in a body built as a whole (an imported part's faces in the
|
||||
/// file's order): `face:<k>`.
|
||||
pub(crate) fn numbered_tool(body: Body) -> Traced {
|
||||
let roles = (0..body.face_count()).map(|k| vec![format!("face:{k}")]).collect();
|
||||
Traced::made(body, Some(roles))
|
||||
}
|
||||
|
||||
/// The role of an extrude's cap at `height` along the sketch normal: on the sketch plane, above
|
||||
/// it or below it.
|
||||
pub(crate) fn extrude_cap_role(height: f64) -> String {
|
||||
let tol = 1e-6 * (1.0 + height.abs());
|
||||
if height.abs() <= tol {
|
||||
"start".into()
|
||||
} else if height > 0.0 {
|
||||
"top".into()
|
||||
} else {
|
||||
"bottom".into()
|
||||
}
|
||||
}
|
||||
|
||||
/// The history over no operands of a body built and then moved (or otherwise rebuilt in place)
|
||||
/// by `moved`'s operation from `built`.
|
||||
pub(crate) fn built_history(moved: &Body, built: &Body) -> Option<History> {
|
||||
let mut history = moved.history()?.clone();
|
||||
if history.operands == 1 {
|
||||
history.substitute(0, built.history()?).ok()?;
|
||||
Some(history)
|
||||
} else {
|
||||
(history.operands == 0).then_some(history)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a body of the model being evaluated came to be during the feature being evaluated.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) enum Made {
|
||||
/// Made or changed by the feature, how not known: named from geometry.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// As it was before the feature.
|
||||
Before,
|
||||
/// Made or changed by the feature, with this lineage.
|
||||
Traced(Arc<Lineage>),
|
||||
}
|
||||
|
||||
impl crate::ModelBody {
|
||||
/// A body made or changed by the feature, with what it came from.
|
||||
pub(crate) fn traced_new(name: String, made: Traced, feature: u64) -> Self {
|
||||
crate::ModelBody::new(name, made.body, feature).with_lineage(made.lineage)
|
||||
}
|
||||
|
||||
/// This body with what it came from during the feature (`None`: not known).
|
||||
pub(crate) fn with_lineage(mut self, lineage: Option<Lineage>) -> Self {
|
||||
self.made = match lineage {
|
||||
Some(l) => Made::Traced(Arc::new(l)),
|
||||
None => Made::Unknown,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
/// This body as an operand of the feature being evaluated: a body of the model before it, or
|
||||
/// what the feature made of one so far.
|
||||
pub(crate) fn traced(&self) -> Traced {
|
||||
let lineage = match &self.made {
|
||||
Made::Before => Some(Lineage::of(&self.body, Operand::Body(self.name.clone()))),
|
||||
Made::Traced(l) => Some(l.as_ref().clone()),
|
||||
Made::Unknown => None,
|
||||
};
|
||||
Traced { body: self.body.clone(), lineage }
|
||||
}
|
||||
|
||||
/// This body, as it was before the feature, as an operand named by `operand` (a moved copy of
|
||||
/// it, the cavity side of a shell grown from it); `None` once the feature has changed it.
|
||||
pub(crate) fn before_as(&self, operand: Operand) -> Traced {
|
||||
let lineage = matches!(self.made, Made::Before).then(|| Lineage::of(&self.body, operand));
|
||||
Traced { body: self.body.clone(), lineage }
|
||||
}
|
||||
|
||||
/// The lineage a feature left this body with, when it made or changed it with one.
|
||||
pub(crate) fn lineage(&self) -> Option<Arc<Lineage>> {
|
||||
match &self.made {
|
||||
Made::Traced(l) => Some(l.clone()),
|
||||
Made::Before | Made::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name the pieces a body fell into after their faces: of the bodies in `bodies` that are lumps
|
||||
/// of one body (made in the same step, from one stage), the one whose least face name sorts first
|
||||
/// takes the first of their body names, and so on. The kernel's shell order, which named them
|
||||
/// before, can swap when an upstream edit moves the cut; their faces' names follow history.
|
||||
/// `origin` is the feature that made them, for naming their faces.
|
||||
pub(crate) fn order_lumps(bodies: &mut [crate::ModelBody], origin: &Arc<crate::naming::NamingOrigin>) {
|
||||
let mut families: Vec<(*const Operand, Vec<usize>)> = Vec::new();
|
||||
for (i, b) in bodies.iter().enumerate() {
|
||||
let Made::Traced(l) = &b.made else { continue };
|
||||
let [op] = l.operands.as_slice() else { continue };
|
||||
if !matches!(op.as_ref(), Operand::Stage(_)) {
|
||||
continue;
|
||||
}
|
||||
let key = Arc::as_ptr(op);
|
||||
match families.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, members)) => members.push(i),
|
||||
None => families.push((key, vec![i])),
|
||||
}
|
||||
}
|
||||
for (_, members) in families.into_iter().filter(|(_, m)| m.len() > 1) {
|
||||
let feature = members.first().and_then(|i| bodies.get(*i)).map(|b| b.feature);
|
||||
if members.iter().any(|i| bodies.get(*i).map(|b| b.feature) != feature) {
|
||||
continue;
|
||||
}
|
||||
let least = |b: &crate::ModelBody| {
|
||||
let mut named = b.clone();
|
||||
named.names = crate::naming::NameCell::traced(Some(origin.clone()), b.lineage());
|
||||
crate::naming::face_names(&named).iter().min().cloned()
|
||||
};
|
||||
let keys: Option<Vec<(String, usize)>> = members.iter().map(|i| bodies.get(*i).and_then(least).map(|k| (k, *i))).collect();
|
||||
let Some(mut keys) = keys else { continue };
|
||||
keys.sort();
|
||||
let names: Vec<String> = members.iter().filter_map(|i| bodies.get(*i).map(|b| b.name.clone())).collect();
|
||||
for (name, (_, i)) in names.into_iter().zip(keys) {
|
||||
if let Some(b) = bodies.get_mut(i) {
|
||||
b.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Finding a face or edge again by the name a feature stored for it.
|
||||
//!
|
||||
//! The face with that name is the face; else the one face answering to it (a face merged into
|
||||
//! it, or the named piece told apart by the faces around it, which still bound it); else, when
|
||||
//! several faces are pieces of the named one, the piece nearest the reference's point, and the
|
||||
//! feature says the face was split. Names saved before format 14 ([`LEGACY_MARK`]) are found
|
||||
//! among the names geometry gives, as the builds that saved them found them. A face whose name
|
||||
//! was worked out from geometry, found where the reference's point no longer lies, is reported:
|
||||
//! only geometry decided.
|
||||
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::history_names::{Match, Names};
|
||||
use crate::naming::{self, closest, edge_names, face_names, legacy_edge_names, legacy_face_names, strip_piece};
|
||||
use crate::piece_names::{strip_edge_number, strip_edge_piece};
|
||||
use crate::{ModelBody, ModelState};
|
||||
|
||||
/// The mark on a name saved before format 14 (`?F2:top#2`). Those names were worked out from
|
||||
/// geometry, and a few of them name a face that exact history names differently (a tube's outer
|
||||
/// wall was named after the nearest sketch curve to its centre, its inner circle): such a name is
|
||||
/// found again among the names that geometry gives today, as the build that saved it did, so an
|
||||
/// older design evaluates as it was saved. Picking the face again stores its exact name.
|
||||
pub const LEGACY_MARK: char = '?';
|
||||
|
||||
/// A name saved before format 14, without its mark (`None`: an exact name).
|
||||
pub fn legacy_name(name: &str) -> Option<&str> {
|
||||
name.strip_prefix(LEGACY_MARK)
|
||||
}
|
||||
|
||||
/// Where a stored edge name was found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EdgeHit {
|
||||
/// A point on the edge: the reference's own point when it lies on it, else the edge's middle.
|
||||
pub point: Vec3,
|
||||
/// The named edge is not one edge any more: the piece nearest the point was taken.
|
||||
pub split: bool,
|
||||
/// Whether the names of the faces it separates follow exact history (and the edge is not
|
||||
/// one of several between them, numbered by where they lie).
|
||||
pub verified: bool,
|
||||
/// Whether the reference's point still lies on the edge.
|
||||
pub on_point: bool,
|
||||
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
|
||||
/// between the faces it names (followed through splits and merges of those faces); else the
|
||||
/// piece of it nearest `near` (`split`). `Err` explains when there is none.
|
||||
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
|
||||
if let Some(old) = legacy_name(name) {
|
||||
return legacy_locate_edge(b, old, near).map_err(|_| format!("edge `{name}` no longer exists"));
|
||||
}
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
let faces = naming::names(b);
|
||||
let mid = |i: usize| edge_mid(&m, i);
|
||||
// Several edges between the same two faces are numbered by where they lie: not exact.
|
||||
let verified = |i: usize| {
|
||||
m.edge_faces.get(i).is_some_and(|fs| fs.iter().all(|f| faces.verified(*f as usize)))
|
||||
&& names.get(i).is_some_and(|n| strip_edge_number(n).len() == n.len())
|
||||
};
|
||||
let exact = |i: usize| -> Result<EdgeHit, String> {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
mid(i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: verified(i), on_point: on, legacy: false })
|
||||
.ok_or_else(|| "edge".into())
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
return exact(i);
|
||||
}
|
||||
// By the faces it separates: the best way any edge matches.
|
||||
let wanted = strip_edge_number(name);
|
||||
let mut best: Option<(Match, Vec<usize>)> = None;
|
||||
for (i, fs) in m.edge_faces.iter().enumerate() {
|
||||
let [fa, fb] = fs.as_slice() else { continue };
|
||||
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
|
||||
keep_best(&mut best, level, i);
|
||||
}
|
||||
let (level, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
|
||||
if let ([i], true) = (cands.as_slice(), level >= Match::Piece) {
|
||||
return exact(*i);
|
||||
}
|
||||
cands
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i).map(|p| (p, *i)))
|
||||
.min_by(|a, b| a.0.dist(near).total_cmp(&b.0.dist(near)))
|
||||
.map(|(p, i)| EdgeHit { point: p, split: true, verified: verified(i), on_point: false, legacy: false })
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
}
|
||||
|
||||
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
|
||||
/// reference keeps it.
|
||||
fn edge_near(b: &ModelBody, m: &Mesh, i: usize, near: Vec3) -> bool {
|
||||
m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)))
|
||||
}
|
||||
|
||||
/// The point of face `face` of a mesh nearest `p`, and how far it is.
|
||||
fn nearest_on_face(m: &Mesh, face: usize, p: Vec3) -> Option<(f64, Vec3)> {
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
if *f as usize != face {
|
||||
continue;
|
||||
}
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, b, c);
|
||||
let d = q.dist(p);
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// The middle of edge `i` of a mesh (along its polyline).
|
||||
fn edge_mid(m: &Mesh, i: usize) -> Option<Vec3> {
|
||||
let e = m.edges.get(i)?;
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if acc + l >= total / 2.0 {
|
||||
let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 };
|
||||
return Some(w[0] + (w[1] - w[0]) * t);
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
e.first().copied()
|
||||
}
|
||||
|
||||
/// [`locate_edge`] for a name saved before format 14 (without its mark), as those builds found
|
||||
/// it: the edge with that name among the names geometry gives, else the piece of it nearest
|
||||
/// `near`.
|
||||
fn legacy_locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, ()> {
|
||||
let m = b.mesh();
|
||||
let names = legacy_edge_names(b);
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
let on = edge_near(b, &m, i, near);
|
||||
return edge_mid(&m, i)
|
||||
.map(|p| EdgeHit { point: if on { near } else { p }, split: false, verified: false, on_point: on, legacy: true })
|
||||
.ok_or(());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
(0..names.len())
|
||||
.filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem))
|
||||
.filter_map(|i| edge_mid(&m, i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| EdgeHit { point: p, split: true, verified: false, on_point: false, legacy: true })
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
/// How well the stored edge name `wanted` (without its own number) names the edge between faces
|
||||
/// `a` and `b`: the weaker of its two faces' matches, read every way the name splits into two
|
||||
/// face names (a face name may hold `|` itself: a fillet face is named after its edge).
|
||||
fn edge_match(faces: &Names, a: usize, b: usize, wanted: &str) -> Option<Match> {
|
||||
let mut best = None;
|
||||
for (i, _) in wanted.match_indices('|') {
|
||||
let (Some(x), Some(y)) = (wanted.get(..i), wanted.get(i + 1..)) else { continue };
|
||||
for (p, q) in [(a, b), (b, a)] {
|
||||
if let (Some(m1), Some(m2)) = (crate::history_names::matches(faces, p, x), crate::history_names::matches(faces, q, y)) {
|
||||
best = best.max(Some(m1.min(m2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Keep `item` among the best matches so far.
|
||||
fn keep_best<T>(best: &mut Option<(Match, Vec<T>)>, level: Match, item: T) {
|
||||
match best {
|
||||
Some((b, items)) if *b == level => items.push(item),
|
||||
Some((b, _)) if *b > level => {}
|
||||
_ => *best = Some((level, vec![item])),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a stored face name was found.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FaceHit {
|
||||
/// The body (its index in the model's bodies).
|
||||
pub body: usize,
|
||||
/// The face (its index in the body).
|
||||
pub face: usize,
|
||||
/// The point on the face nearest the reference's point.
|
||||
pub point: Vec3,
|
||||
/// The named face is not one face any more (split, or found on several bodies): the piece
|
||||
/// nearest the point was taken.
|
||||
pub split: bool,
|
||||
/// The named face was merged into this one.
|
||||
pub merged: bool,
|
||||
/// Whether the face's name follows exact history.
|
||||
pub verified: bool,
|
||||
/// Whether the reference's point still lies on the face (so a name worked out from geometry
|
||||
/// and the pick agree).
|
||||
pub on_point: bool,
|
||||
/// The name was saved before format 14 and found as that build did ([`LEGACY_MARK`]).
|
||||
pub legacy: bool,
|
||||
}
|
||||
|
||||
impl FaceHit {
|
||||
/// What a feature says about how it found a face by its stored name `name` (`None`: exactly).
|
||||
pub fn note(&self, name: &str) -> Option<String> {
|
||||
if self.split {
|
||||
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
|
||||
} else if self.merged {
|
||||
Some(format!("face `{name}` was merged into another face; using that face"))
|
||||
} else if !self.verified && !self.on_point && !self.legacy {
|
||||
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EdgeHit {
|
||||
/// What a feature says about how it found an edge by its stored name `name` (`None`: exactly).
|
||||
pub fn note(&self, name: &str) -> Option<String> {
|
||||
if self.split {
|
||||
Some(format!("edge `{name}` was split; using the piece nearest the picked point"))
|
||||
} else if !self.verified && !self.on_point && !self.legacy {
|
||||
Some(format!("edge `{name}` was found by geometry, not exact history; check it"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The face a stored face name finds, on any body: the face with that name, or the one face
|
||||
/// answering to it (merged into it, or the named piece told apart by the faces around it); else
|
||||
/// the piece of it nearest `p` (`split`).
|
||||
pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p)
|
||||
}
|
||||
|
||||
/// [`locate_face`] among `bodies` (with their indices).
|
||||
fn locate_in<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
if let Some(old) = legacy_name(name) {
|
||||
return legacy_locate(bodies, old, p);
|
||||
}
|
||||
let mut best: Option<(Match, Vec<(usize, &ModelBody, usize)>)> = None;
|
||||
for (bi, b) in bodies {
|
||||
let names = naming::names(b);
|
||||
for face in 0..names.faces.len() {
|
||||
if let Some(level) = crate::history_names::matches(&names, face, name) {
|
||||
keep_best(&mut best, level, (bi, b, face));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (level, faces) = best?;
|
||||
let unique = faces.len() == 1 && level >= Match::Piece;
|
||||
let mut nearest: Option<(f64, usize, &ModelBody, usize, Vec3)> = None;
|
||||
for (bi, b, face) in faces {
|
||||
if let Some((d, q)) = nearest_on_face(&b.mesh(), face, p)
|
||||
&& nearest.as_ref().is_none_or(|n| d < n.0)
|
||||
{
|
||||
nearest = Some((d, bi, b, face, q));
|
||||
}
|
||||
}
|
||||
let (distance, body, b, face, point) = nearest?;
|
||||
let on_point = distance <= (b.body.size() * 1e-3).max(1e-3);
|
||||
Some(FaceHit {
|
||||
body,
|
||||
face,
|
||||
point,
|
||||
split: !unique,
|
||||
merged: level == Match::Alias,
|
||||
verified: naming::names(b).verified(face),
|
||||
on_point,
|
||||
legacy: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`locate_face`] for a name saved before format 14 (without its mark), as those builds found
|
||||
/// it: among the names geometry gives, the face with that name (or a piece of the same face, if
|
||||
/// nearer `p`), the nearest point on it.
|
||||
fn legacy_locate<'a>(bodies: impl Iterator<Item = (usize, &'a ModelBody)>, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, usize, usize, Vec3, bool, f64)> = None;
|
||||
for (bi, b) in bodies {
|
||||
let names = legacy_face_names(b);
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
let m = b.mesh();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some(n) = names.get(*f as usize) else { continue };
|
||||
let exact = n == name;
|
||||
if !exact && strip_piece(n) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, bb, c);
|
||||
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
|
||||
if best.is_none_or(|x| d < x.0) {
|
||||
best = Some((d, bi, *f as usize, q, !exact, tol));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(d, body, face, point, split, tol)| FaceHit {
|
||||
body,
|
||||
face,
|
||||
point,
|
||||
split,
|
||||
merged: false,
|
||||
verified: false,
|
||||
on_point: d <= tol,
|
||||
legacy: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether face `index` of a body is the face a stored name names: that name (or one merged into
|
||||
/// it); for a name saved before format 14, that name among the names those builds gave.
|
||||
pub fn face_has_name(b: &ModelBody, index: usize, name: &str) -> bool {
|
||||
match legacy_name(name) {
|
||||
Some(old) => legacy_face_names(b).get(index).is_some_and(|n| n == old),
|
||||
None => crate::history_names::matches(&naming::names(b), index, name).is_some_and(|m| m >= Match::Alias),
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces of a body that are the stored name's face or pieces of it (by stem), as the name's
|
||||
/// own era names them.
|
||||
pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
|
||||
let (names, wanted) = match legacy_name(name) {
|
||||
Some(old) => (legacy_face_names(b), old),
|
||||
None => (face_names(b), name),
|
||||
};
|
||||
let stem = strip_piece(wanted);
|
||||
let aliases =
|
||||
|i: usize| legacy_name(name).is_none() && naming::names(b).info.get(i).is_some_and(|x| x.aliases.iter().any(|a| strip_piece(a) == stem));
|
||||
(0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_piece(n) == stem) || aliases(*i)).collect()
|
||||
}
|
||||
|
||||
/// [`locate_face`] on one body.
|
||||
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(std::iter::once((0, body)), name, p)
|
||||
}
|
||||
+131
-127
@@ -1,16 +1,26 @@
|
||||
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
|
||||
//! Persistent names of faces and edges.
|
||||
//!
|
||||
//! Every face of an evaluated body is named after how it was made: a face that lies on a face
|
||||
//! of the feature's input keeps that face's name; a face new to the feature is named by its role
|
||||
//! (`F<feature>:side:<sketch curve>`, `F<feature>:start`/`end`, `F<feature>:blend:<k>`, …).
|
||||
//! Pieces of a split face get `#1`, `#2`, … along the longest axis. Edges are named by the faces
|
||||
//! they separate. Names are worked out lazily, on first request, from the body, the feature
|
||||
//! that made it and the model before that feature.
|
||||
//! A fillet stores the edges it rounds, a sketch the face it sits on: by name, so that an
|
||||
//! upstream edit that adds, removes, splits or reorders faces does not move them to another
|
||||
//! face. Every face of an evaluated body is named after how it was made: a face that came from a
|
||||
//! face of the feature's input keeps that face's name; a face new to the feature is named by its
|
||||
//! role (`F<feature>:side:<sketch curve>`, `F<feature>:start`/`top`/`bottom`/`end`,
|
||||
//! `F<feature>:blend:<edge>`, `F<feature>:inner:<face>`, …). Pieces of a split face are told
|
||||
//! apart by the faces around them (`F2:top#[F2:side:l4,F4:side:l4]`). Edges are named by the
|
||||
//! faces they separate (`<face A>|<face B>`, `~k` for several edges between the same two).
|
||||
//!
|
||||
//! Where the feature that made a body reports exact history (`crate::lineage`), the names follow
|
||||
//! it (`crate::history_names`). Bodies without history (made by operations that do not report
|
||||
//! it yet, imported meshes, legacy chains) are named from geometry: sample points of each face
|
||||
//! are looked up on the input's faces. Those names are marked unverified, and references that
|
||||
//! resolve through them say so. Names are worked out lazily, on first request.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solvecraft_geom::{Mesh, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::history_names::Names;
|
||||
use crate::lineage::Lineage;
|
||||
use crate::{Feature, FeatureKind, ModelBody, ModelState};
|
||||
|
||||
/// How a body came to be: the feature and the model just before it.
|
||||
@@ -27,21 +37,43 @@ pub struct NamingOrigin {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NameCell {
|
||||
pub origin: Option<Arc<NamingOrigin>>,
|
||||
names: Arc<OnceLock<Arc<Vec<String>>>>,
|
||||
/// What the body's faces came from, when the feature that made it reports it.
|
||||
lineage: Option<Arc<Lineage>>,
|
||||
names: Arc<OnceLock<Arc<Names>>>,
|
||||
/// The names designs saved before format 14 gave its faces (see [`LEGACY_MARK`]).
|
||||
legacy: Arc<OnceLock<Arc<Vec<String>>>>,
|
||||
/// The body this one is a moved copy of (its legacy names are that body's).
|
||||
copy_of: Option<Arc<ModelBody>>,
|
||||
}
|
||||
|
||||
impl NameCell {
|
||||
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
|
||||
NameCell { origin, names: Arc::new(OnceLock::new()) }
|
||||
Self::traced(origin, None)
|
||||
}
|
||||
/// Names already known (a moved copy of a body keeps its faces' names).
|
||||
/// Names of a body made by `origin`'s feature, following `lineage` where it is known.
|
||||
pub(crate) fn traced(origin: Option<Arc<NamingOrigin>>, lineage: Option<Arc<Lineage>>) -> Self {
|
||||
NameCell { origin, lineage, ..NameCell::default() }
|
||||
}
|
||||
/// Names already known, taken as they are.
|
||||
pub fn known(names: Arc<Vec<String>>) -> Self {
|
||||
let legacy = OnceLock::new();
|
||||
let _ = legacy.set(names.clone());
|
||||
NameCell { legacy: Arc::new(legacy), ..Self::shared(Arc::new(Names::given(names)), None) }
|
||||
}
|
||||
/// The names of `body` (a moved copy of a body keeps its faces' names, and how sure they are).
|
||||
pub fn same(body: &ModelBody) -> Self {
|
||||
Self::shared(names(body), Some(Arc::new(body.clone())))
|
||||
}
|
||||
fn shared(names: Arc<Names>, copy_of: Option<Arc<ModelBody>>) -> Self {
|
||||
let cell = OnceLock::new();
|
||||
let _ = cell.set(names);
|
||||
NameCell { origin: None, names: Arc::new(cell) }
|
||||
NameCell { names: Arc::new(cell), copy_of, ..NameCell::default() }
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
|
||||
pub use crate::piece_names::parent_piece;
|
||||
|
||||
/// Deeper chains than this are named by position only (bounded work and stack).
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
@@ -80,7 +112,7 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
if d1 <= 0.0 && d2 <= 0.0 {
|
||||
@@ -158,12 +190,53 @@ fn face_count(b: &ModelBody) -> usize {
|
||||
|
||||
/// Names of a body's faces (by B-rep face index).
|
||||
pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
names(b).faces.clone()
|
||||
}
|
||||
|
||||
/// Whether face `index`'s name follows exact history (`false`: worked out from geometry, which an
|
||||
/// upstream edit can fool).
|
||||
pub fn face_name_exact(b: &ModelBody, index: usize) -> bool {
|
||||
names(b).verified(index)
|
||||
}
|
||||
|
||||
/// A body's names, with what finding them again needs: from the history of the feature that
|
||||
/// made it where it reports one, else from geometry (unverified).
|
||||
pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
|
||||
b.names
|
||||
.names
|
||||
.get_or_init(|| {
|
||||
// Exact history needs no mesh; geometry does (its face count is the mesh's).
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => b
|
||||
.names
|
||||
.lineage
|
||||
.as_ref()
|
||||
.and_then(|l| crate::history_names::derive(b, o, l))
|
||||
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
|
||||
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
|
||||
};
|
||||
Arc::new(names)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The names builds before format 14 gave a body's faces: from geometry, over the inputs' names
|
||||
/// as those builds gave them, pieces of a split face numbered along the longest axis of their
|
||||
/// spread (`#1`, `#2`, …).
|
||||
pub(crate) fn legacy_face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
if let Some(source) = &b.names.copy_of {
|
||||
return legacy_face_names(source);
|
||||
}
|
||||
b.names
|
||||
.legacy
|
||||
.get_or_init(|| {
|
||||
let nf = face_count(b);
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => derive(b, o, nf),
|
||||
Some(o) if o.depth < MAX_DEPTH => {
|
||||
let mut names = geometric_from(b, o, nf, legacy_face_names);
|
||||
number_pieces(&mut names, &samples(&b.mesh(), nf), '#');
|
||||
names
|
||||
}
|
||||
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
|
||||
};
|
||||
Arc::new(names)
|
||||
@@ -171,14 +244,31 @@ pub fn face_names(b: &ModelBody) -> Arc<Vec<String>> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Work out a body's face names from the feature that made it and the model before it.
|
||||
fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
/// The names builds before format 14 gave a body's edges.
|
||||
pub(crate) fn legacy_edge_names(b: &ModelBody) -> Vec<String> {
|
||||
edge_names_from(b, &legacy_face_names(b))
|
||||
}
|
||||
|
||||
/// Pairs of faces of a body that share an edge (from its mesh's edges).
|
||||
pub(crate) fn mesh_adjacency(b: &ModelBody) -> Vec<(usize, usize)> {
|
||||
crate::history_names::face_pairs(b.mesh().edge_faces.iter().map(|fs| fs.iter().map(|f| *f as usize)))
|
||||
}
|
||||
|
||||
/// Work out a body's face names from the feature that made it and the model before it, by
|
||||
/// geometry: where each face's sample points lie on the input's faces. Faces sharing a name are
|
||||
/// pieces of one, told apart afterwards.
|
||||
pub(crate) fn geometric_names(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
geometric_from(b, o, nf, face_names)
|
||||
}
|
||||
|
||||
/// [`geometric_names`], the input bodies' faces named by `names_of`.
|
||||
fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&ModelBody) -> Arc<Vec<String>>) -> Vec<String> {
|
||||
// A rigid Move changes coordinates, not topology or face ownership. Geometric
|
||||
// overlap with the unmoved input cannot recover the names at the new position.
|
||||
if matches!(o.feature.kind, FeatureKind::Move { .. })
|
||||
&& let Some(input) = o.before.body(&b.name)
|
||||
{
|
||||
return face_names(input).as_ref().clone();
|
||||
return names_of(input).as_ref().clone();
|
||||
}
|
||||
let m = b.mesh();
|
||||
let size = b.body.size().max(1e-6);
|
||||
@@ -214,7 +304,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
.take(64)
|
||||
.map(|x| {
|
||||
let n = face_count(x);
|
||||
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
|
||||
(x, FaceIndex::new(&x.mesh(), n), names_of(x))
|
||||
})
|
||||
.collect();
|
||||
let fid = o.feature.id;
|
||||
@@ -229,7 +319,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
.iter()
|
||||
.filter(|x| !x.body.is_mesh())
|
||||
.take(64)
|
||||
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
|
||||
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), names_of(x)))
|
||||
.collect();
|
||||
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
|
||||
for (k, mat) in o.mats.iter().enumerate() {
|
||||
@@ -245,7 +335,7 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
let empty = Vec::new();
|
||||
let mut names: Vec<String> = names
|
||||
let names: Vec<String> = names
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(fi, n)| {
|
||||
@@ -261,7 +351,6 @@ fn derive(b: &ModelBody, o: &NamingOrigin, nf: usize) -> Vec<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
number_pieces(&mut names, &pts, '#');
|
||||
names
|
||||
}
|
||||
|
||||
@@ -347,37 +436,23 @@ fn vote(ps: &[Vec3], inputs: &[(&ModelBody, FaceIndex, Arc<Vec<String>>)], tol:
|
||||
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`).
|
||||
/// A face name without its piece qualifiers: the face it is a piece of (`F2:top` for
|
||||
/// `F2:top#[F4:side:l1]`, for pieces of pieces, and for the numbered pieces of older designs).
|
||||
pub fn strip_piece(n: &str) -> &str {
|
||||
strip_mark(n, '#')
|
||||
let mut stem = n;
|
||||
while let Some(parent) = parent_piece(stem) {
|
||||
stem = parent;
|
||||
}
|
||||
stem
|
||||
}
|
||||
|
||||
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#k`), so pieces of a
|
||||
/// split edge, or of an edge between split faces, match.
|
||||
/// An edge name without piece numbers: its own (`~k`) and its faces' (`#[…]`, and the numbered
|
||||
/// pieces of older designs), so pieces of a split edge, or of an edge between split faces, match.
|
||||
pub fn strip_edge_piece(n: &str) -> String {
|
||||
let n = strip_mark(n, '~');
|
||||
let mut out = String::with_capacity(n.len());
|
||||
let mut it = n.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
if c == '#' && it.peek().is_some_and(|d| d.is_ascii_digit()) {
|
||||
while it.peek().is_some_and(|d| d.is_ascii_digit()) {
|
||||
it.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
crate::piece_names::strip_edge_piece(n)
|
||||
}
|
||||
|
||||
fn strip_mark(n: &str, mark: char) -> &str {
|
||||
match n.rfind(mark) {
|
||||
Some(i) if n[i + 1..].chars().all(|c| c.is_ascii_digit()) && i + 1 < n.len() => &n[..i],
|
||||
_ => n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Faces sharing a name are pieces of one: number them along the longest axis of their spread.
|
||||
/// Edges sharing a name: number them along the longest axis of their spread.
|
||||
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for i in 0..names.len() {
|
||||
@@ -429,18 +504,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6);
|
||||
if flat {
|
||||
// On the sketch plane, above it or below it.
|
||||
let h = (c - ss.plane.origin).dot(n);
|
||||
let tol = 1e-6 * (1.0 + h.abs());
|
||||
return format!(
|
||||
"F{fid}:{}",
|
||||
if h.abs() <= tol {
|
||||
"start"
|
||||
} else if h > 0.0 {
|
||||
"top"
|
||||
} else {
|
||||
"bottom"
|
||||
}
|
||||
);
|
||||
return format!("F{fid}:{}", crate::lineage::extrude_cap_role((c - ss.plane.origin).dot(n)));
|
||||
}
|
||||
let q = ss.plane.to_local(c);
|
||||
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
|
||||
@@ -576,8 +640,12 @@ fn seg_dist(s: &Seg2, q: Vec2) -> f64 {
|
||||
|
||||
/// Names of a body's edges (by mesh edge index): `<face A>|<face B>`, numbered when repeated.
|
||||
pub fn edge_names(b: &ModelBody) -> Vec<String> {
|
||||
edge_names_from(b, &face_names(b))
|
||||
}
|
||||
|
||||
/// Names of a body's edges from its faces' names `faces`.
|
||||
fn edge_names_from(b: &ModelBody, faces: &[String]) -> Vec<String> {
|
||||
let m = b.mesh();
|
||||
let faces = face_names(b);
|
||||
let mut names: Vec<String> = m
|
||||
.edge_faces
|
||||
.iter()
|
||||
@@ -595,35 +663,7 @@ pub fn edge_names(b: &ModelBody) -> Vec<String> {
|
||||
/// The edge of a body with this name: exact, else the pieces of the same edge nearest `near`
|
||||
/// (`Err` explains when there is none).
|
||||
pub fn find_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<(Vec3, bool), String> {
|
||||
let m = b.mesh();
|
||||
let names = edge_names(b);
|
||||
let mid = |i: usize| -> Option<Vec3> {
|
||||
let e = m.edges.get(i)?;
|
||||
let total: f64 = e.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let mut acc = 0.0;
|
||||
for w in e.windows(2) {
|
||||
let l = w[0].dist(w[1]);
|
||||
if acc + l >= total / 2.0 {
|
||||
let t = if l > 0.0 { (total / 2.0 - acc) / l } else { 0.0 };
|
||||
return Some(w[0] + (w[1] - w[0]) * t);
|
||||
}
|
||||
acc += l;
|
||||
}
|
||||
e.first().copied()
|
||||
};
|
||||
if let Some(i) = names.iter().position(|n| n == name) {
|
||||
// Keep the picked point when it is on that edge.
|
||||
let on = m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)));
|
||||
return mid(i).map(|p| (if on { near } else { p }, false)).ok_or_else(|| "edge".into());
|
||||
}
|
||||
let stem = strip_edge_piece(name);
|
||||
let cands: Vec<usize> = (0..names.len()).filter(|i| names.get(*i).is_some_and(|n| strip_edge_piece(n) == stem)).collect();
|
||||
cands
|
||||
.iter()
|
||||
.filter_map(|i| mid(*i))
|
||||
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
|
||||
.map(|p| (p, true))
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"))
|
||||
locate_edge(b, name, near).map(|hit| (hit.point, hit.split))
|
||||
}
|
||||
|
||||
/// Names of the edges nearest each point on a body (for storing references).
|
||||
@@ -675,28 +715,10 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
|
||||
/// the name.
|
||||
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
|
||||
let stem = strip_piece(name);
|
||||
let mut best: Option<(f64, Vec3, bool)> = None;
|
||||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||||
let names = face_names(b);
|
||||
let m = b.mesh();
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some(n) = names.get(*f as usize) else { continue };
|
||||
let exact = n == name;
|
||||
if !exact && strip_piece(n) != stem {
|
||||
continue;
|
||||
}
|
||||
let Some([a, bb, c]) = m.tri(t) else { continue };
|
||||
let q = closest(p, a, bb, c);
|
||||
let d = q.dist(p) - if exact { 1e-9 } else { 0.0 };
|
||||
if best.is_none_or(|(bd, _, _)| d < bd) {
|
||||
best = Some((d, q, !exact));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, q, split)| (q, split))
|
||||
locate_face(st, name, p).map(|hit| (hit.point, hit.split || hit.merged))
|
||||
}
|
||||
|
||||
/// Resolve a legacy named face only on its recorded source body. Return the
|
||||
@@ -706,25 +728,7 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
|
||||
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))
|
||||
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point, hit.split || hit.merged))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -259,6 +259,7 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::MeshConvert { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! The grammar of piece names: a face split into pieces is named `<face>#[<faces around it>]`
|
||||
//! (the faces only that piece touches), `#k` after that when nothing tells pieces apart, and
|
||||
//! designs saved before format 14 numbered pieces `<face>#k`. Parsing never panics on hostile
|
||||
//! names: an unbalanced or empty qualifier is simply not one.
|
||||
|
||||
/// The face a piece name is a piece of (one qualifier off), `None` when it is not a piece.
|
||||
pub fn parent_piece(n: &str) -> Option<&str> {
|
||||
piece_split(n).map(|(stem, _)| stem)
|
||||
}
|
||||
|
||||
/// A piece name's stem and the faces its qualifier lists (`F2:top`, [`F2:side:l4`, `F4:side:l4`]
|
||||
/// for `F2:top#[F2:side:l4,F4:side:l4]`; none for an older design's numbered piece).
|
||||
pub(crate) fn piece_parts(n: &str) -> Option<(&str, Vec<&str>)> {
|
||||
let (stem, around) = piece_split(n)?;
|
||||
Some((stem, around.map(split_top_level).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// `n` as stem and qualifier contents: `stem#[…]`, `stem#[…]#k` (a numbered tie) or `stem#k` (an
|
||||
/// older design's numbered piece, no contents).
|
||||
fn piece_split(n: &str) -> Option<(&str, Option<&str>)> {
|
||||
let mut rest = n;
|
||||
let mut numbered = false;
|
||||
if let Some(i) = rest.rfind('#')
|
||||
&& let Some(digits) = rest.get(i + 1..)
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
rest = rest.get(..i)?;
|
||||
numbered = true;
|
||||
}
|
||||
if let Some(inner_end) = rest.len().checked_sub(1).filter(|_| rest.ends_with(']')) {
|
||||
let open = matching_open(rest, inner_end)?;
|
||||
let stem = rest.get(..open.checked_sub(1)?)?;
|
||||
if rest.get(open - 1..open) == Some("#") && !stem.is_empty() {
|
||||
return Some((stem, rest.get(open + 1..inner_end)));
|
||||
}
|
||||
}
|
||||
(numbered && !rest.is_empty()).then_some((rest, None))
|
||||
}
|
||||
|
||||
/// The index of the `[` matching the `]` at byte `close`.
|
||||
fn matching_open(s: &str, close: usize) -> Option<usize> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut depth = 0usize;
|
||||
for i in (0..=close).rev() {
|
||||
match bytes.get(i)? {
|
||||
b']' => depth += 1,
|
||||
b'[' => {
|
||||
depth = depth.checked_sub(1)?;
|
||||
if depth == 0 {
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `s` split at the commas outside brackets (a qualifier's list of faces).
|
||||
fn split_top_level(s: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let (mut depth, mut from) = (0usize, 0usize);
|
||||
for (i, b) in s.bytes().enumerate() {
|
||||
match b {
|
||||
b'[' => depth += 1,
|
||||
b']' => depth = depth.saturating_sub(1),
|
||||
b',' if depth == 0 => {
|
||||
out.extend(s.get(from..i).filter(|x| !x.is_empty()));
|
||||
from = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.extend(s.get(from..).filter(|x| !x.is_empty()));
|
||||
out
|
||||
}
|
||||
|
||||
/// An edge name without piece qualifiers: its own number (`~k`) and its faces' (`#[…]`, and the
|
||||
/// numbered pieces of older designs), so pieces of a split edge, or of an edge between split
|
||||
/// faces, match.
|
||||
pub(crate) fn strip_edge_piece(n: &str) -> String {
|
||||
let n = strip_edge_number(n);
|
||||
let mut out = String::with_capacity(n.len());
|
||||
let bytes = n.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes.get(i) == Some(&b'#') {
|
||||
match bytes.get(i + 1) {
|
||||
Some(b'[') => {
|
||||
// Skip the bracketed qualifier (and a tie number after it).
|
||||
let mut depth = 0usize;
|
||||
let mut j = i + 1;
|
||||
while j < bytes.len() {
|
||||
match bytes.get(j) {
|
||||
Some(b'[') => depth += 1,
|
||||
Some(b']') => {
|
||||
depth = depth.saturating_sub(1);
|
||||
if depth == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
Some(d) if d.is_ascii_digit() => {
|
||||
i += 1;
|
||||
while bytes.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Copy one whole character.
|
||||
let ch = n.get(i..).and_then(|rest| rest.chars().next());
|
||||
match ch {
|
||||
Some(c) => {
|
||||
out.push(c);
|
||||
i += c.len_utf8();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An edge name without its own number (`~k`).
|
||||
pub(crate) fn strip_edge_number(n: &str) -> &str {
|
||||
match n.rfind('~') {
|
||||
Some(i) if n.get(i + 1..).is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) => n.get(..i).unwrap_or(n),
|
||||
_ => n,
|
||||
}
|
||||
}
|
||||
@@ -497,7 +497,7 @@ fn refresh_links_in(
|
||||
&& 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)
|
||||
&& crate::naming::face_has_name(target, index, stem)
|
||||
});
|
||||
if removed_piece {
|
||||
sk.set_link_lost(&l.id, true);
|
||||
|
||||
+209
-8
@@ -1134,7 +1134,29 @@ impl SheetBody {
|
||||
match panel {
|
||||
None => {
|
||||
let pts = self.base.outer.ccw().polyline(1e-3);
|
||||
let cut = clip(&pts, a + inward * by, inward);
|
||||
let o = a + inward * by;
|
||||
let mut cut = clip(&pts, o, inward);
|
||||
// Only the material along this edge goes: other parts of the outline past the
|
||||
// line (tabs side by side on one line) stay.
|
||||
let mut work = FlatWork::new();
|
||||
let off = merge_loops(&[clip(&pts, o, inward * -1.0)], &mut work)?;
|
||||
if off.len() > 1 {
|
||||
let mid = (a + b) * 0.5;
|
||||
let touches = |p: &Vec<Vec2>| {
|
||||
let m = p.len();
|
||||
(0..m).filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?))).any(|(p0, p1)| {
|
||||
let e = p1 - p0;
|
||||
let t = if e.len2() > 1e-18 { ((mid - p0).dot(e) / e.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(p0 + e * t).dist(mid) < 1e-6
|
||||
})
|
||||
};
|
||||
let mut keep = vec![cut];
|
||||
keep.extend(off.into_iter().filter(|p| !touches(p)));
|
||||
cut = match merge_loops(&keep, &mut work)?.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => return Err(DocError::Invalid("not supported yet: a flange setback that splits the sheet".into())),
|
||||
};
|
||||
}
|
||||
if cut.len() < 3 {
|
||||
return Err(DocError::Invalid("the flange's setback removes the whole panel".into()));
|
||||
}
|
||||
@@ -1190,13 +1212,13 @@ impl SheetBody {
|
||||
return Err(DocError::Invalid("pick an edge of the sheet's top or bottom face".into()));
|
||||
}
|
||||
let d = (b - a).normalized().ok_or_else(|| DocError::Invalid("edge".into()))?;
|
||||
found.push((panel, a, d, top));
|
||||
found.push((panel, a, b, d, top));
|
||||
}
|
||||
for (panel, a, d, _) in &found {
|
||||
self.trim(*panel, *a, *a + *d, setback)?;
|
||||
for (panel, a, b, _, _) in &found {
|
||||
self.trim(*panel, *a, *b, setback)?;
|
||||
}
|
||||
let mut made = Vec::new();
|
||||
for (panel, a, d, top) in found {
|
||||
for (panel, a, b, d, top) in found {
|
||||
let outward = Vec2::new(d.y, -d.x);
|
||||
let line_o = a - outward * setback;
|
||||
// The trimmed edge: along d, on the moved line.
|
||||
@@ -1204,14 +1226,21 @@ impl SheetBody {
|
||||
None => {
|
||||
let pts = self.base.outer.ccw().polyline(1e-3);
|
||||
let m = pts.len();
|
||||
(0..m)
|
||||
let on_line: Vec<(Vec2, f64)> = (0..m)
|
||||
.filter_map(|k| {
|
||||
let (p0, p1) = (*pts.get(k)?, *pts.get((k + 1) % m)?);
|
||||
let e = p1 - p0;
|
||||
let on = (p0 - line_o).dot(outward).abs() < 1e-6 && (p1 - line_o).dot(outward).abs() < 1e-6;
|
||||
(on && e.dot(d) > 0.0).then_some((p0, e.len()))
|
||||
})
|
||||
.next()
|
||||
.collect();
|
||||
// Of several edges on the line (tabs side by side), the one the pick was on.
|
||||
let (ua, ub) = (a.dot(d), b.dot(d));
|
||||
on_line
|
||||
.iter()
|
||||
.find(|(p0, len)| (p0.dot(d) + len).min(ub) - p0.dot(d).max(ua) > 1e-9)
|
||||
.or_else(|| on_line.first())
|
||||
.copied()
|
||||
.ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))?
|
||||
}
|
||||
Some(i) => {
|
||||
@@ -1355,7 +1384,29 @@ impl SheetBody {
|
||||
}
|
||||
(None, Some(p)) => {
|
||||
let q = apply_point(&inv, p);
|
||||
Vec2::new(q.x, q.y).dot(n) < l0
|
||||
let q2 = Vec2::new(q.x, q.y);
|
||||
// A point in the panel's slab must be on material: inside the outline and
|
||||
// outside every cut-out (a side face of either counts as material).
|
||||
let near = |poly: &[Vec2]| {
|
||||
let m = poly.len();
|
||||
(0..m).any(|i| match (poly.get(i), poly.get((i + 1) % m)) {
|
||||
(Some(&a), Some(&b)) => {
|
||||
let d = b - a;
|
||||
let t = if d.len2() > 1e-18 { ((q2 - a).dot(d) / d.len2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(a + d * t).dist(q2) < 1e-3
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
let in_slab = q.z > -1e-4 && q.z < self.t + 1e-4;
|
||||
let in_hole = self.holes.iter().any(|h| h.contains(q2) && !near(&h.outer.ccw().polyline(1e-3)));
|
||||
if in_slab && ((!point_in(&pts, q2) && !near(&pts)) || in_hole) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"the fixed point ({:.3}, {:.3}, {:.3}) is not on the sheet: pick a point on the face of the side that stays",
|
||||
p.x, p.y, p.z
|
||||
)));
|
||||
}
|
||||
q2.dot(n) < l0
|
||||
}
|
||||
(None, None) => side_area(n) <= side_area(n * -1.0),
|
||||
};
|
||||
@@ -1378,11 +1429,32 @@ impl SheetBody {
|
||||
if signed_area(&fixed_part).abs() < 1e-9 || signed_area(&moving).abs() < 1e-9 {
|
||||
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
|
||||
}
|
||||
// Separate tabs on one line (the zone falls apart into several pieces): fold the ones
|
||||
// the line's points span, each as its own bend.
|
||||
let strips = merge_loops(std::slice::from_ref(&strip), &mut FlatWork::new())?;
|
||||
if strips.len() > 1 {
|
||||
let span = (a2.dot(d).min(b2.dot(d)), a2.dot(d).max(b2.dot(d)));
|
||||
let angle = if flip { -angle } else { angle };
|
||||
return self.fold_tabs(panel, &pts, (n, d), (s0, ba), span, (angle, radius), &strips);
|
||||
}
|
||||
// The bend zone must be a rectangle across the panel (a slanted or curved edge would
|
||||
// run across the bend as a helix, which the sheet's faces can't carry yet).
|
||||
let us: Vec<f64> = strip.iter().map(|q| q.dot(d)).collect();
|
||||
let (u0, u1) = (us.iter().cloned().fold(f64::MAX, f64::min), us.iter().cloned().fold(f64::MIN, f64::max));
|
||||
if ((u1 - u0) * ba - signed_area(&strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) {
|
||||
// A corner of the outline inside the zone (e.g. a fold right at a tab's root): name
|
||||
// it and how far the line has to move to clear it.
|
||||
let corners: Vec<(Vec2, f64)> = pts.iter().map(|q| (*q, q.dot(n) - s0)).filter(|(_, s)| *s > 1e-6 && *s < ba - 1e-6).collect();
|
||||
if let Some(&(c, _)) = corners.first() {
|
||||
let on = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(*s));
|
||||
let back = corners.iter().fold(0.0_f64, |m, (_, s)| m.max(ba - *s));
|
||||
let w = apply_point(&self.world(panel), Vec3::new(c.x, c.y, self.t));
|
||||
return Err(DocError::Invalid(format!(
|
||||
"the bend zone ({ba:.3} mm wide) reaches a corner of the sheet's outline at ({:.3}, {:.3}, {:.3}): \
|
||||
move the fold line at least {on:.3} mm toward the side that folds or {back:.3} mm toward the side that stays",
|
||||
w.x, w.y, w.z
|
||||
)));
|
||||
}
|
||||
return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into()));
|
||||
}
|
||||
let up = !flip;
|
||||
@@ -1436,6 +1508,135 @@ impl SheetBody {
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
/// A fold whose bend zone falls apart into several pieces (`strips`: tabs on one line). The
|
||||
/// pieces the line's points span (`span`, along `d`) bend, each with the material beyond it;
|
||||
/// the rest of the panel stays put. `n` points to the moving side, the zone is `s0..s0 + ba`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fold_tabs(
|
||||
&mut self,
|
||||
panel: Option<usize>,
|
||||
pts: &[Vec2],
|
||||
(n, d): (Vec2, Vec2),
|
||||
(s0, ba): (f64, f64),
|
||||
span: (f64, f64),
|
||||
(angle, radius): (f64, f64),
|
||||
strips: &[Vec<Vec2>],
|
||||
) -> Result<usize> {
|
||||
const EPS: f64 = 1e-6;
|
||||
let urange = |p: &[Vec2]| p.iter().fold((f64::MAX, f64::MIN), |(lo, hi), q| (lo.min(q.dot(d)), hi.max(q.dot(d))));
|
||||
let overlap = |a: (f64, f64), b: (f64, f64)| a.1.min(b.1) - a.0.max(b.0);
|
||||
let mut work = FlatWork::new();
|
||||
// The material beyond the zone, piece by piece, and the zone piece each one hangs from.
|
||||
let beyond = merge_loops(&[clip(pts, n * (s0 + ba), n)], &mut work)?;
|
||||
let far_edges = |p: &[Vec2]| -> Vec<(f64, f64)> {
|
||||
let m = p.len();
|
||||
(0..m)
|
||||
.filter_map(|i| Some((*p.get(i)?, *p.get((i + 1) % m)?)))
|
||||
.filter(|(a, b)| (a.dot(n) - s0 - ba).abs() < EPS && (b.dot(n) - s0 - ba).abs() < EPS)
|
||||
.map(|(a, b)| (a.dot(d).min(b.dot(d)), a.dot(d).max(b.dot(d))))
|
||||
.collect()
|
||||
};
|
||||
let strip_u: Vec<(f64, f64)> = strips.iter().map(|s| urange(s)).collect();
|
||||
let mut hangs: Vec<Vec<usize>> = vec![Vec::new(); strips.len()];
|
||||
for (k, part) in beyond.iter().enumerate() {
|
||||
let edges = far_edges(part);
|
||||
let on: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| edges.iter().any(|&e| overlap(e, u) > EPS))).collect();
|
||||
match on.as_slice() {
|
||||
[i] => {
|
||||
if let Some(h) = hangs.get_mut(*i) {
|
||||
h.push(k);
|
||||
}
|
||||
}
|
||||
_ => return Err(DocError::Invalid("not supported yet: folding tabs that join again beyond the bend".into())),
|
||||
}
|
||||
}
|
||||
let chosen: Vec<usize> = (0..strips.len()).filter(|&i| strip_u.get(i).is_some_and(|&u| overlap(u, span) > EPS)).collect();
|
||||
if chosen.is_empty() {
|
||||
return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into()));
|
||||
}
|
||||
// What stays: the panel short of the zone plus the zone pieces (and what hangs from
|
||||
// them) that don't fold.
|
||||
let mut keep = vec![clip(pts, n * s0, n * -1.0)];
|
||||
for i in 0..strips.len() {
|
||||
if chosen.contains(&i) {
|
||||
continue;
|
||||
}
|
||||
keep.extend(strips.get(i).cloned());
|
||||
keep.extend(hangs.get(i).into_iter().flatten().filter_map(|&k| beyond.get(k).cloned()));
|
||||
}
|
||||
let fixed = match merge_loops(&keep, &mut work)?.as_slice() {
|
||||
[one] if signed_area(one) > 1e-9 => one.clone(),
|
||||
_ => return Err(DocError::Invalid("not supported yet: a fold that splits the part that stays".into())),
|
||||
};
|
||||
let mut made = Vec::new();
|
||||
for &i in &chosen {
|
||||
let (Some(strip), Some(&(u0, u1))) = (strips.get(i), strip_u.get(i)) else { continue };
|
||||
// Each piece of the zone must be a rectangle (see `add_fold`).
|
||||
if ((u1 - u0) * ba - signed_area(strip).abs()).abs() > 1e-6 * (u1 - u0).max(1.0) * ba.max(1.0) {
|
||||
return Err(DocError::Invalid("not supported yet: folds whose bend zone crosses a slanted or curved edge of the sheet".into()));
|
||||
}
|
||||
let moving = match hangs.get(i).map(Vec::as_slice) {
|
||||
Some([k]) => beyond.get(*k).cloned().ok_or_else(|| DocError::Invalid("fold".into()))?,
|
||||
_ => return Err(DocError::Invalid("the fold line must cross the sheet with material on both sides of the bend".into())),
|
||||
};
|
||||
let leg = moving.iter().map(|q| q.dot(n) - s0 - ba).fold(0.0, f64::max);
|
||||
made.push((u0, u1, n * s0 + d * u0, leg, moving));
|
||||
}
|
||||
// Flanges already on this panel: those beyond a folding tab move with it.
|
||||
let mut moves = Vec::new();
|
||||
for k in 0..self.flanges.len() {
|
||||
let Some(f) = self.flanges.get(k) else { continue };
|
||||
if f.parent != panel {
|
||||
continue;
|
||||
}
|
||||
let (e0, e1) = (f.p.dot(n) - s0, (f.p + f.d * f.len).dot(n) - s0);
|
||||
let fu = (f.p.dot(d).min((f.p + f.d * f.len).dot(d)), f.p.dot(d).max((f.p + f.d * f.len).dot(d)));
|
||||
let crosses = Err(DocError::Invalid("not supported yet: a fold through a flange's bend".into()));
|
||||
match made.iter().position(|m| fu.0 >= m.0 - 1e-9 && fu.1 <= m.1 + 1e-9) {
|
||||
Some(j) if e0.min(e1) >= ba - 1e-9 => moves.push((k, j)),
|
||||
Some(_) if e0.max(e1) > 1e-9 => return crosses,
|
||||
None if e0.max(e1) > 1e-9 && made.iter().any(|m| overlap((m.0, m.1), fu) > EPS) => return crosses,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ccw = |mut v: Vec<Vec2>| {
|
||||
if signed_area(&v) < 0.0 {
|
||||
v.reverse();
|
||||
}
|
||||
v.into_iter().map(snap).collect::<Vec<_>>()
|
||||
};
|
||||
match panel {
|
||||
None => self.base = Region2 { outer: Loop2::polygon(&fixed).ccw(), holes: self.base.holes.clone() },
|
||||
Some(j) => {
|
||||
if let Some(f) = self.flanges.get_mut(j) {
|
||||
f.shape = Some(ccw(fixed));
|
||||
}
|
||||
}
|
||||
}
|
||||
let first = self.flanges.len();
|
||||
for (u0, u1, p0, leg, moving) in made {
|
||||
self.flanges.push(SheetFlange {
|
||||
parent: panel,
|
||||
p: snap(p0),
|
||||
d,
|
||||
len: u1 - u0,
|
||||
n,
|
||||
angle,
|
||||
radius,
|
||||
leg,
|
||||
ext: (0.0, 0.0),
|
||||
shape: Some(ccw(moving)),
|
||||
tag: String::new(),
|
||||
});
|
||||
}
|
||||
for (k, j) in moves {
|
||||
if let Some(f) = self.flanges.get_mut(k) {
|
||||
f.parent = Some(first + j);
|
||||
}
|
||||
}
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
/// Name a panel edge (flat coordinates a→b on `panel`, top or bottom face): base edges by the
|
||||
/// base sketch line they lie on, flange edges by the flange (its tag) and side.
|
||||
fn edge_name_of(&self, panel: Option<usize>, a: Vec2, b: Vec2, top: bool) -> Option<String> {
|
||||
|
||||
@@ -64,3 +64,27 @@ fn oblique_cut_through_a_small_arc_matches_a_split() {
|
||||
assert!((got - above).abs() < 1e-6 * above, "{got} vs {above}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Joining struts that end on a bore wall reached a truck-topology panic (#268). The guard caught it,
|
||||
/// but no kernel call should panic at all (wasm aborts on a panic).
|
||||
#[test]
|
||||
fn struts_ending_on_a_bore_wall_join_without_a_panic() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let me = std::thread::current().id();
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let c2 = count.clone();
|
||||
let prev = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |_| {
|
||||
if std::thread::current().id() == me {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}));
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/join_struts_on_bore_wall.json")).unwrap();
|
||||
let result = s.run_script(&script);
|
||||
std::panic::set_hook(prev);
|
||||
assert_eq!(count.load(Ordering::SeqCst), 0, "kernel panics during the join");
|
||||
result.unwrap();
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
@@ -445,13 +445,14 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.body(name).is_some() {
|
||||
return Err(bad(cmd, format!("another body is called `{name}`")));
|
||||
}
|
||||
// The body is the k-th new body of its feature; the names before it are kept as they are.
|
||||
// The body is the k-th new body of its feature; its siblings keep the names they have now
|
||||
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
|
||||
let fid = b.feature;
|
||||
let siblings: Vec<String> = st.bodies.iter().filter(|x| x.feature == fid).map(|x| x.name.clone()).collect();
|
||||
let k = siblings.iter().position(|n| *n == old).unwrap_or(0);
|
||||
let d = s.doc_mut();
|
||||
let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?;
|
||||
while f.body_names.len() <= k {
|
||||
while f.body_names.len() < siblings.len().max(k + 1) {
|
||||
let i = f.body_names.len();
|
||||
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
|
||||
}
|
||||
|
||||
@@ -108,7 +108,25 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
|
||||
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());
|
||||
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order.
|
||||
let anchored = solvecraft_doc::Feature { shell_piece_order: true, ..anchored };
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
// Names saved before format 14 keep naming the face they named, marked so they are found as
|
||||
// the build that saved them found them.
|
||||
let mark = |name: &Option<String>| name.as_ref().map(|n| format!("{}{n}", solvecraft_doc::naming::LEGACY_MARK));
|
||||
let mut saved_plane = saved_plane;
|
||||
if let solvecraft_doc::PlaneRef::Face { name, .. } = &mut saved_plane {
|
||||
*name = mark(name);
|
||||
}
|
||||
let paint: Vec<_> = paint
|
||||
.into_iter()
|
||||
.map(|mut face| {
|
||||
if !face.strict_name {
|
||||
face.name = mark(&face.name);
|
||||
}
|
||||
face
|
||||
})
|
||||
.collect();
|
||||
let FeatureKind::Sketch { plane, .. } = &reopened.doc.feature(badge).unwrap().kind else { panic!() };
|
||||
assert_eq!(plane, &saved_plane);
|
||||
assert_eq!(reopened.doc.appearances.faces, paint);
|
||||
@@ -156,10 +174,10 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
|
||||
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.
|
||||
// The outer wall is named after its own sketch curve: its history says so. (Builds before
|
||||
// format 14 named it after the curve nearest its centre, the inner circle; see below.)
|
||||
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
|
||||
assert!(name.contains("side:inner"));
|
||||
assert!(name.contains("side:outer"));
|
||||
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!({}));
|
||||
@@ -172,6 +190,13 @@ fn legacy_v9_annulus_outer_side_emboss_keeps_its_original_heuristic_anchor() {
|
||||
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");
|
||||
// What a version 9 design stored for that pick: the centroid-family name of the outer wall
|
||||
// piece holding the point. It keeps finding that piece, as it did when saved.
|
||||
for feature in legacy["features"].as_array_mut().unwrap() {
|
||||
if feature["id"] == json!(feature_id) {
|
||||
feature["face_names"] = json!(["F2:side:inner#4"]);
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -90,6 +90,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
Sel::Edge { .. } | Sel::Vertex { .. } => skip("edges and vertices are deleted with their body or face".into()),
|
||||
Sel::Profile { .. } => skip("a profile goes with its sketch".into()),
|
||||
Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => skip("edit the source sketch to delete its geometry".into()),
|
||||
}
|
||||
}
|
||||
features.dedup();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("delete")
|
||||
.key("Delete")
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it)"),
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).params("feature: id|name, name"),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback)
|
||||
@@ -120,6 +120,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if keys.is_empty() {
|
||||
return Err(bad(cmd, "`features` must list features"));
|
||||
}
|
||||
// Features that work now; any of them failing after the delete is reported (a later
|
||||
// feature that names a deleted body, #267).
|
||||
let worked: std::collections::BTreeSet<u64> = s.model.results.iter().filter(|r| r.error.is_none() && !r.skipped).map(|r| r.id).collect();
|
||||
let mut gone = Vec::new();
|
||||
for k in keys {
|
||||
let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else {
|
||||
@@ -134,7 +137,18 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.active_sketch = None;
|
||||
}
|
||||
s.selection.clear();
|
||||
Ok(json!({"deleted": gone}))
|
||||
s.refresh();
|
||||
let failing: Vec<Value> = s
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| worked.contains(&r.id))
|
||||
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
|
||||
.collect();
|
||||
if failing.is_empty() {
|
||||
return Ok(json!({"deleted": gone}));
|
||||
}
|
||||
Ok(json!({"deleted": gone, "failing": failing}))
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -145,6 +159,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc.features.iter().any(|f| f.name == name && f.id != id) {
|
||||
return Err(bad(cmd, format!("another feature is called `{name}`")));
|
||||
}
|
||||
if s.doc.feature(id).is_some_and(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })) {
|
||||
super::features::check_plane_name(name, cmd)?;
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.name = name.to_string();
|
||||
}
|
||||
@@ -261,6 +278,7 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"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()),
|
||||
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?"),
|
||||
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
|
||||
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?"),
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.fillet", "Fillet", fillet)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("fillet")
|
||||
@@ -87,7 +87,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
|
||||
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"),
|
||||
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
@@ -359,10 +359,17 @@ pub(super) fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str
|
||||
|
||||
/// Add a feature, evaluate, and report its result (an evaluation error fails the command).
|
||||
pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
|
||||
add_feature_in(s, p, kind, true)
|
||||
}
|
||||
|
||||
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
|
||||
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
// Commands speak world coordinates; the feature is authored in the active component.
|
||||
let mut kind = kind;
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
if world {
|
||||
super::component::to_active_frame(s, &mut kind);
|
||||
}
|
||||
// Bare lengths typed in an inch (or cm…) design mean that unit.
|
||||
if s.doc.units.trim() != "mm" {
|
||||
let doc = s.doc.clone();
|
||||
@@ -406,9 +413,19 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
|
||||
}
|
||||
};
|
||||
let participants = string_list(p, "participants");
|
||||
// Primitives have no `targets` of their own: their `targets` are the bodies taking part (#259).
|
||||
let primitive = matches!(kind, FeatureKind::Box { .. } | FeatureKind::Cylinder { .. } | FeatureKind::Sphere { .. } | FeatureKind::Torus { .. });
|
||||
let (participants, key) = match string_list(p, "participants") {
|
||||
v if v.is_empty() && primitive => (string_list(p, "targets"), "targets"),
|
||||
v => (v, "participants"),
|
||||
};
|
||||
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
|
||||
return Err(EngineError::Other("`participants` must list existing bodies".into()));
|
||||
return Err(EngineError::Other(format!("`{key}` must list existing bodies")));
|
||||
}
|
||||
if matches!(kind, FeatureKind::ConstructionPlane { .. })
|
||||
&& let Some(n) = name
|
||||
{
|
||||
check_plane_name(n, "construct.plane")?;
|
||||
}
|
||||
let id = s.doc_mut().add_feature(kind, name)?;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
@@ -431,10 +448,33 @@ pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Resu
|
||||
if !names.is_empty()
|
||||
&& let Some(f) = s.doc_mut().feature_mut(id)
|
||||
{
|
||||
f.body_names = names;
|
||||
f.body_names = names.clone();
|
||||
}
|
||||
s.active_sketch = None;
|
||||
s.refresh();
|
||||
// A feature that makes more bodies than it was given names (a multi-profile extrude with one
|
||||
// `body_name`) names the rest after the first: `Ribs`, `Ribs (2)`, `Ribs (3)`… (#266). The
|
||||
// names are stored on the feature, so recomputes and saved designs keep them.
|
||||
if let Some(base) = names.first().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()) {
|
||||
let st = s.model.state();
|
||||
let made = st.bodies.iter().filter(|b| b.feature == id).count();
|
||||
if made > names.len() {
|
||||
let mut taken: std::collections::BTreeSet<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
taken.extend(names.iter().cloned());
|
||||
let mut i = 1usize;
|
||||
while names.len() < made {
|
||||
i += 1;
|
||||
let n = format!("{base} ({i})");
|
||||
if taken.insert(n.clone()) {
|
||||
names.push(n);
|
||||
}
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.body_names = names;
|
||||
}
|
||||
s.refresh();
|
||||
}
|
||||
}
|
||||
let r = s.model.result(id).cloned();
|
||||
if let Some(e) = r.as_ref().and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
@@ -854,7 +894,12 @@ fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
|
||||
_ => Err(bad(cmd, "axis must be X, Y, Z or {origin, dir}")),
|
||||
},
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let origin = match o.get("origin") {
|
||||
None => Vec3::ZERO,
|
||||
Some(v) => vec3(v).ok_or_else(|| {
|
||||
bad(cmd, "axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported")
|
||||
})?,
|
||||
};
|
||||
let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?;
|
||||
Ok((origin, dir))
|
||||
}
|
||||
@@ -965,6 +1010,31 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine })
|
||||
}
|
||||
|
||||
/// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one.
|
||||
pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> {
|
||||
match name.trim().to_ascii_uppercase().as_str() {
|
||||
"XY" => Some("Top"),
|
||||
"TOP" => Some("XY"),
|
||||
"XZ" => Some("Front"),
|
||||
"FRONT" => Some("XZ"),
|
||||
"YZ" => Some("Right"),
|
||||
"RIGHT" => Some("YZ"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A construction plane may not take an origin plane's name (`XY`, `Top`...): a plane
|
||||
/// parameter could then mean either.
|
||||
pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> {
|
||||
if solvecraft_geom::Plane::named(name.trim()).is_some() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim()),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A plane reference from a parameter: an origin plane, a construction plane (by name or
|
||||
/// feature id; kept by name, so the feature follows the plane when it changes), or an
|
||||
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. Every command that takes a
|
||||
@@ -978,7 +1048,22 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
|
||||
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
|
||||
};
|
||||
match v {
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() }),
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n.trim()).is_some() => {
|
||||
if let Some(f) = s.doc.find_feature(n.trim())
|
||||
&& matches!(f.kind, FeatureKind::ConstructionPlane { .. })
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"plane `{}` is ambiguous: an origin-plane alias and a construction plane; use `{}` for the origin plane, construction plane feature id {}, or an explicit {{origin, normal}} plane",
|
||||
n.trim(),
|
||||
origin_plane_alias(n).unwrap_or("XY"),
|
||||
f.id
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
|
||||
}
|
||||
Some(Value::String(n)) => construction(n.trim()),
|
||||
Some(Value::Number(id)) => match id.as_u64() {
|
||||
Some(id) => construction(&id.to_string()),
|
||||
@@ -1002,9 +1087,17 @@ pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<s
|
||||
|
||||
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "construct.plane.offset";
|
||||
let base = plane_param(s, p.get("base"), cmd)?;
|
||||
let d = req_expr(cmd, p, "offset")?;
|
||||
check_expr(s, &d, Kind::Length, cmd, "offset")?;
|
||||
// A planar body face: attached exactly, as a sketch on that face is, so the plane follows it.
|
||||
if let Some(face) = p.get("base").filter(|b| b.get("face").is_some()) {
|
||||
let base = super::sketch::face::capture(s, face, cmd)?;
|
||||
super::sketch::face::capture_positions(s, &base)?;
|
||||
let kind = FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } };
|
||||
// The capture is already in the authoring frame.
|
||||
return add_feature_in(s, p, kind, false);
|
||||
}
|
||||
let base = plane_param(s, p.get("base"), cmd)?;
|
||||
add_feature(s, p, FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } })
|
||||
}
|
||||
|
||||
@@ -1429,3 +1522,7 @@ mod offset_identity_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "offset_edit_guard_tests.rs"]
|
||||
mod offset_edit_guard_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_plane_face_tests.rs"]
|
||||
mod offset_plane_face_tests;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("SurfaceSculpt", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
|
||||
CommandSpec::new("solid.boundary_fill", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
|
||||
"tools: [bodies]; cells: [[x,y,z] a point inside each cell to fill (a cell: inside some tools, outside the rest)]; operation?: new|join|cut|intersect; remove_tools?: bool",
|
||||
),
|
||||
CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params(
|
||||
@@ -422,7 +422,7 @@ pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option<Vec3> {
|
||||
}
|
||||
|
||||
fn boundary_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "SurfaceSculpt";
|
||||
let cmd = "solid.boundary_fill";
|
||||
let tools = string_list(p, "tools");
|
||||
let st = s.model.state();
|
||||
if tools.is_empty() || tools.len() > 64 || tools.iter().any(|t| st.body(t).is_none()) {
|
||||
|
||||
@@ -205,6 +205,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms),
|
||||
"boolean_diagnostics": r.map(|r| &r.boolean_diagnostics),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -261,7 +262,10 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.body
|
||||
.faces(tol)?
|
||||
.iter()
|
||||
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
|
||||
.map(|f| {
|
||||
json!({"index": f.index, "name": names.get(f.index), "exact": solvecraft_doc::naming::face_name_exact(b, f.index),
|
||||
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Parametric reconstruction of imported mechanical mesh bodies.
|
||||
|
||||
use serde_json::Value;
|
||||
use solvecraft_doc::FeatureKind;
|
||||
use solvecraft_doc::expr::Kind;
|
||||
|
||||
use super::{
|
||||
CommandSpec,
|
||||
features::{add_feature, check_expr},
|
||||
};
|
||||
use crate::{
|
||||
Result, Session,
|
||||
params::{bad, expr, str_},
|
||||
};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
|
||||
.at("MESH", "MODIFY")
|
||||
.icon("import")
|
||||
.params("body: mesh body name; tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature; name?"),
|
||||
];
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "mesh.convert";
|
||||
let body = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "select a mesh body"))?;
|
||||
let state = s.model.state();
|
||||
let source = state.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
if !source.body.is_mesh() {
|
||||
return Err(bad(cmd, "select a mesh body"));
|
||||
}
|
||||
if s.doc.model_body_component(source) != s.active_component {
|
||||
return Err(bad(cmd, "activate the mesh body's component before converting it"));
|
||||
}
|
||||
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0.05 mm".into());
|
||||
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
|
||||
add_feature(s, p, FeatureKind::MeshConvert { body: body.into(), tolerance })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The command registry. Ids are SolveCraft's own dotted ids (`solid.extrude`,
|
||||
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
|
||||
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
|
||||
//! Design workspace layout.
|
||||
|
||||
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
|
||||
macro_rules! created_doc {
|
||||
@@ -26,6 +26,7 @@ mod file;
|
||||
mod inspect;
|
||||
pub(crate) mod joints;
|
||||
mod measure_sel;
|
||||
mod mesh;
|
||||
pub(crate) mod motion;
|
||||
mod parts;
|
||||
mod section;
|
||||
@@ -157,6 +158,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(features_more::COMMANDS.iter());
|
||||
v.extend(mesh::COMMANDS.iter());
|
||||
v.extend(construct::COMMANDS.iter());
|
||||
v.extend(sheet::COMMANDS.iter());
|
||||
v.extend(plastic::COMMANDS.iter());
|
||||
@@ -178,7 +180,5 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
|
||||
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
let all = command_specs();
|
||||
// Ids from before the rename still find their command.
|
||||
let id = crate::legacy_ids::current_id(id);
|
||||
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::construct::ConstructGeom;
|
||||
use solvecraft_doc::{Document, FeatureKind, PlaneRef};
|
||||
use solvecraft_geom::Plane;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command} {params}: {e}"))
|
||||
}
|
||||
|
||||
fn plane(s: &Session, name: &str) -> Plane {
|
||||
let state = s.model.state();
|
||||
match state.construct.iter().find(|c| c.name == name).map(|c| &c.geom) {
|
||||
Some(ConstructGeom::Plane(p)) => *p,
|
||||
other => panic!("construction plane `{name}`: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_plane_from_a_planar_face_follows_the_face() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20, "name": "Base"}));
|
||||
|
||||
// Top face (z = 20), 5 mm out along its normal.
|
||||
run(&mut s, "construct.plane.offset", json!({"base": {"face": [5, 5, 20]}, "offset": 5, "name": "Above"}));
|
||||
let above = plane(&s, "Above");
|
||||
assert!((above.origin.z - 25.0).abs() < 1e-9, "{above:?}");
|
||||
assert!((above.normal().z.abs() - 1.0).abs() < 1e-9, "{above:?}");
|
||||
// Stored as an exact face attachment, not a frozen copy of the plane.
|
||||
let feature = s.doc.find_feature("Above").unwrap();
|
||||
let FeatureKind::ConstructionPlane { plane: PlaneRef::Offset { base, .. } } = &feature.kind else { panic!("offset plane") };
|
||||
assert!(matches!(base.as_ref(), PlaneRef::Face { anchor: Some(_), .. }), "{base:?}");
|
||||
|
||||
// Side face (x = 40), 3 mm out.
|
||||
run(&mut s, "construct.plane.offset", json!({"base": {"face": [40, 15, 10]}, "offset": 3, "name": "Side"}));
|
||||
let side = plane(&s, "Side");
|
||||
assert!((side.origin.x - 43.0).abs() < 1e-9, "{side:?}");
|
||||
assert!((side.normal().x.abs() - 1.0).abs() < 1e-9, "{side:?}");
|
||||
|
||||
// A 10 × 10 × 10 pad sketched on the new plane sits 5 mm above the box.
|
||||
run(&mut s, "sketch.create", json!({"plane": "Above"}));
|
||||
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, "operation": "new"}));
|
||||
s.refresh();
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
assert!((volume(&s) - (40.0 * 30.0 * 20.0 + 1000.0)).abs() < 1e-6, "{}", volume(&s));
|
||||
|
||||
// Taller and longer box: both planes move with their faces.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "30", "length": "50"}}));
|
||||
assert!((plane(&s, "Above").origin.z - 35.0).abs() < 1e-9);
|
||||
assert!((plane(&s, "Side").origin.x - 53.0).abs() < 1e-9);
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
|
||||
// Saved and reopened, the attachment still resolves.
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
assert!((plane(&reopened, "Above").origin.z - 35.0).abs() < 1e-9);
|
||||
assert!((plane(&reopened, "Side").origin.x - 53.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_plane_rejects_bad_face_picks_without_panicking() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20}));
|
||||
for base in [
|
||||
json!({"face": [100, 100, 100]}),
|
||||
json!({"face": [1e300, 0, 0]}),
|
||||
json!({"face": "top"}),
|
||||
json!({"face": [5, 5]}),
|
||||
json!({"face": [5, 5, 20], "index": 0}),
|
||||
json!({"face": [5, 5, 20], "body": 7, "index": 0}),
|
||||
] {
|
||||
let features = s.doc.features.len();
|
||||
assert!(s.execute("construct.plane.offset", &json!({"base": base, "offset": 5})).is_err(), "{base}");
|
||||
assert_eq!(s.doc.features.len(), features, "{base}");
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,71 @@ fn plate_with_hole() -> Session {
|
||||
const PLATE: f64 = 100.0 * 50.0 * 5.0;
|
||||
const HOLE: f64 = PI * 4.0 * 5.0;
|
||||
|
||||
#[test]
|
||||
fn circular_axis_origin_rejects_invalid_values_atomically() {
|
||||
let mut s = plate_with_hole();
|
||||
run(&mut s, "parameters.add", json!({"name":"cx","expression":"50 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name":"cy","expression":"25 mm"}));
|
||||
let before = s.doc.to_json();
|
||||
let undo = s.undo.len();
|
||||
for origin in [
|
||||
json!(["cx", "cy", 0]),
|
||||
json!(["50", 25, 0]),
|
||||
json!([0, 0]),
|
||||
json!([0, 0, 0, 0]),
|
||||
json!(null),
|
||||
json!(false),
|
||||
json!({"x":0,"y":0}),
|
||||
json!([1e9, 0, 0]),
|
||||
json!([0, 1e100, 0]),
|
||||
] {
|
||||
for (cmd, params) in [
|
||||
("solid.pattern.circular", json!({"features":["Hole1"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4})),
|
||||
("construct.plane.at_angle", json!({"base":"XY","axis":{"origin":origin,"dir":[1,0,0]},"angle":45})),
|
||||
] {
|
||||
let error = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("axis.origin") && error.contains("expressions are not supported"), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_numeric_axis_origin_preserves_hole_geometry_and_reopens() {
|
||||
for origin in [json!([50, 50, 0]), json!({"x":50,"y":50,"z":0})] {
|
||||
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":[100,100]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[50,20],"radius":5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10,"operation":"cut","name":"Hole"}));
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "solid.pattern.circular", json!({"features":["Hole"],"axis":{"origin":origin,"dir":[0,0,1]},"count":4}));
|
||||
let expected = 100.0 * 100.0 * 10.0 - 4.0 * PI * 25.0 * 10.0;
|
||||
assert!(rel(volume(&mut s), expected) < 1e-4);
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(rel(volume(&mut reopened), expected) < 1e-4);
|
||||
}
|
||||
// The documented omission of origin still means zero.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[20,0,0],"body_name":"Cube"}));
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"dir":[0,0,1]},"count":4}));
|
||||
let implicit = s.doc.to_json();
|
||||
let mut explicit = Session::new(solvecraft_doc::Document::from_json(&before).unwrap());
|
||||
run(&mut explicit, "solid.pattern.circular", json!({"bodies":["Cube"],"axis":{"origin":[0,0,0],"dir":[0,0,1]},"count":4}));
|
||||
assert_eq!(implicit, explicit.doc.to_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_on_path_spacing_extent_and_flip() {
|
||||
let mut s = plate_with_hole();
|
||||
@@ -142,3 +207,48 @@ fn patterns_of_a_blind_hole_drill_every_copy() {
|
||||
));
|
||||
assert!((c - 2.0 * one).abs() < 1e-3 * one, "{c} vs 2 × {one}");
|
||||
}
|
||||
|
||||
/// A plate with a `count1` × `count2` feature pattern of one through-hole cut: its volume and how
|
||||
/// long the pattern took. It runs on its own thread so a hang fails the test instead of CI.
|
||||
fn hole_pattern(count1: usize, count2: usize) -> (f64, std::time::Duration) {
|
||||
use std::time::{Duration, Instant};
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 200, "width": 100, "height": 1, "operation": "new_body", "body_name": "Plate"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "2", "name": "H"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [4, 4], "diameter": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "H", "distance": "4", "direction": "negative", "operation": "cut", "targets": ["Plate"], "name": "Hole"}),
|
||||
);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let t = Instant::now();
|
||||
let r = s.execute(
|
||||
"solid.pattern.rectangular",
|
||||
&json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": count1, "spacing1": 6, "dir2": [0, 1, 0], "count2": count2, "spacing2": 6}),
|
||||
);
|
||||
let took = t.elapsed();
|
||||
let v = r.map(|_| volume(&mut s)).map_err(|e| e.to_string());
|
||||
let _ = tx.send((v, took));
|
||||
});
|
||||
let (v, took) = rx.recv_timeout(Duration::from_secs(900)).expect("the hole pattern did not finish within 900 s");
|
||||
let n = (count1 * count2) as f64;
|
||||
let v = v.expect("pattern");
|
||||
// The measure meshes the bores: each hole reads about 0.06 % small, so allow 0.1 % a hole.
|
||||
assert!((v - (200.0 * 100.0 - n * PI)).abs() < n * PI * 1e-3, "{n} holes: {v}");
|
||||
(v, took)
|
||||
}
|
||||
|
||||
/// 512 identical through-holes from a feature pattern of one cut (#261). 128 took 30 s and 512 did
|
||||
/// not finish in 150 s: each copy was cut into an ever more complex plate. The time must grow about
|
||||
/// linearly with the count. A ratio, not a time: CI runs every test binary at once and is many
|
||||
/// times slower than a machine running this alone (about 6 s for 512 holes there).
|
||||
#[test]
|
||||
fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
|
||||
let (_, t128) = hole_pattern(16, 8);
|
||||
let (_, t512) = hole_pattern(32, 16);
|
||||
// Linear is 4×, the old quadratic cost 16× or more.
|
||||
assert!(t512.as_secs_f64() < 8.0 * t128.as_secs_f64() + 2.0, "128 holes took {t128:?}, 512 took {t512:?}");
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A named plane's world axis: which component of a point lies along its normal.
|
||||
let (origin, normal, axis) = match p.get("plane") {
|
||||
Some(Value::String(n)) if Plane::named(n.trim()).is_some() => {
|
||||
// Apply the same ambiguity check as modelling commands before treating a name as an origin alias.
|
||||
super::features::plane_param(s, p.get("plane"), cmd)?;
|
||||
let pl = Plane::named(n.trim()).ok_or_else(|| bad(cmd, format!("unknown plane `{n}`")))?;
|
||||
let normal = pl.normal();
|
||||
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.flange", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs); name?, body_name? (the new body)",
|
||||
),
|
||||
CommandSpec::new("sheet.hem", "Hem", hem)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
|
||||
@@ -1558,3 +1558,91 @@ fn partial_depth_intersect_sub_extrusion_resolution_layers_refuse_atomically() {
|
||||
assert!((solvecraft_kernel::measure(&s.world_state().body(&name).unwrap().body).unwrap().volume - 15000.0).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0.8 mm steel rule and a base flange from a closed outline on XY (the sheet spans z 0..0.8).
|
||||
fn outline_blank(s: &mut Session, outline: Value) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "Steel 0.8", "thickness": "0.8", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
|
||||
run(s, "sketch.line", json!({"points": outline, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"sketch": "Blank"}));
|
||||
}
|
||||
|
||||
/// A 100 x 40 blank with two 15 mm tabs on its right edge (y 5..12 and y 28..35), on one bend line.
|
||||
fn two_tab_blank(s: &mut Session) {
|
||||
outline_blank(
|
||||
s,
|
||||
json!([[0, 0], [100, 0], [100, 5], [115, 5], [115, 12], [100, 12], [100, 28], [115, 28], [115, 35], [100, 35], [100, 40], [0, 40]]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Folding one tab must not be refused because another tab lies on the same line (#264).
|
||||
#[test]
|
||||
fn fold_one_of_two_collinear_tabs() {
|
||||
let mut s = Session::default();
|
||||
two_tab_blank(&mut s);
|
||||
let flat = volume(&mut s);
|
||||
run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 12, 0.8]], "fixed": [50, 20, 0.8]}));
|
||||
assert!(rel(volume(&mut s), flat) < 1e-3, "a fold keeps the material");
|
||||
}
|
||||
|
||||
/// One fold line across both tabs bends both (#264).
|
||||
#[test]
|
||||
fn fold_across_two_collinear_tabs() {
|
||||
let mut s = Session::default();
|
||||
two_tab_blank(&mut s);
|
||||
let flat = volume(&mut s);
|
||||
run(&mut s, "sheet.fold", json!({"points": [[107, 5, 0.8], [107, 35, 0.8]], "fixed": [50, 20, 0.8]}));
|
||||
assert!(rel(volume(&mut s), flat) < 1e-3);
|
||||
let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
|
||||
assert!(bb["max"][2].as_f64().unwrap() > 7.0, "both tabs stand up: {bb}");
|
||||
}
|
||||
|
||||
/// Edge flanges on both tab ends in one feature (two separate features already work) (#264).
|
||||
#[test]
|
||||
fn edge_flanges_on_two_collinear_tab_ends_in_one_feature() {
|
||||
let mut s = Session::default();
|
||||
two_tab_blank(&mut s);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[115, 8, 0.8], [115, 31, 0.8]], "height": "10"}));
|
||||
let mut separate = Session::default();
|
||||
two_tab_blank(&mut separate);
|
||||
run(&mut separate, "sheet.flange", json!({"edges": [[115, 8, 0.8]], "height": "10"}));
|
||||
run(&mut separate, "sheet.flange", json!({"edges": [[115, 31, 0.8]], "height": "10"}));
|
||||
assert!(rel(volume(&mut s), volume(&mut separate)) < 1e-6);
|
||||
}
|
||||
|
||||
/// `fixed` inside a cut-out touches no material: the fold must refuse it or say which side it kept (#263).
|
||||
#[test]
|
||||
fn fold_fixed_point_off_the_sheet_is_not_silently_ignored() {
|
||||
let mut s = Session::default();
|
||||
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 60], [0, 60]]));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "2", "name": "Window"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 15], "p1": [40, 45]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Window", "distance": "4", "direction": "negative", "operation": "cut"}));
|
||||
match s.execute("sheet.fold", &json!({"points": [[80, 0, 0.8], [80, 60, 0.8]], "fixed": [25, 30, 0.8]})) {
|
||||
Err(e) => assert!(e.to_string().contains("fixed"), "{e}"),
|
||||
Ok(r) => {
|
||||
let warned =
|
||||
["warning", "warnings"].iter().any(|k| r.get(*k).is_some_and(|w| !w.is_null() && w != &json!([]) && w.to_string().contains("fixed")));
|
||||
assert!(warned, "fixed [25, 30] is inside the window, yet the fold succeeded silently: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tab whose fold line sits exactly at its root: the bend zone reaches the blank outline beside
|
||||
/// the tab. Every edge is straight, so the error must not blame a slanted or curved edge (#262).
|
||||
#[test]
|
||||
fn fold_at_a_tab_root_names_the_real_cause() {
|
||||
let mut s = Session::default();
|
||||
outline_blank(&mut s, json!([[0, 0], [100, 0], [100, 50], [60, 50], [60, 70], [40, 70], [40, 50], [0, 50]]));
|
||||
if let Err(e) = s.execute("sheet.fold", &json!({"points": [[40, 50, 0.8], [60, 50, 0.8]], "fixed": [50, 25, 0.8]})) {
|
||||
let e = e.to_string();
|
||||
assert!(!e.contains("slanted or curved"), "all edges are straight: {e}");
|
||||
assert!(e.contains("bend zone"), "name the bend zone reaching the outline: {e}");
|
||||
}
|
||||
// 1 mm into the tab the same fold works.
|
||||
let mut ok = Session::default();
|
||||
outline_blank(&mut ok, json!([[0, 0], [100, 0], [100, 50], [60, 50], [60, 70], [40, 70], [40, 50], [0, 50]]));
|
||||
run(&mut ok, "sheet.fold", json!({"points": [[40, 51, 0.8], [60, 51, 0.8]], "fixed": [50, 25, 0.8]}));
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -1601,7 +1601,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let (unit, default_expr) =
|
||||
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
|
||||
let e = value.clone().unwrap_or(default_expr);
|
||||
let e = value.as_ref().map(|e| doc.with_design_unit(e, if is_angle { Kind::Angle } else { Kind::Length })).unwrap_or(default_expr);
|
||||
let pname = doc.new_model_param(&e, unit);
|
||||
// The value must evaluate.
|
||||
let (vals, errs) = doc.param_values();
|
||||
|
||||
@@ -216,6 +216,47 @@ fn plane_along_a_path() {
|
||||
run(&mut s, "sketch.create", json!({"plane": "P", "name": "OnPath"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_on_a_3d_wire_ignores_its_sketch_plane_and_follows_edits() {
|
||||
let mut s = Session::default();
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":{"origin":[100,200,300],"x_dir":[0,1,0],"y_dir":[0,0,1]}}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[10,20,10],[30,35,40]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let path = json!({"sketch":sketch,"curve":wire});
|
||||
run(&mut s, "construct.plane.along_path", json!({"path":path,"t":0.5,"name":"BridgePlane"}));
|
||||
run(&mut s, "construct.point.along_path", json!({"path":path,"t":0.25,"name":"Quarter"}));
|
||||
let p = plane(&s, "BridgePlane");
|
||||
assert!(near(p.origin, Vec3::new(20.0, 27.5, 25.0)), "{p:?}");
|
||||
assert!(parallel(p.normal(), Vec3::new(20.0, 15.0, 30.0)));
|
||||
assert!(near(point(&s, "Quarter"), Vec3::new(15.0, 23.75, 17.5)));
|
||||
run(&mut s, "sketch.edit", json!({"sketch":sketch}));
|
||||
run(&mut s, "sketch.move3d", json!({"wire":wire,"index":1,"to":[50,35,40]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let updated = plane(&s, "BridgePlane");
|
||||
assert!(near(updated.origin, Vec3::new(30.0, 27.5, 25.0)), "{updated:?}");
|
||||
assert!(parallel(updated.normal(), Vec3::new(40.0, 15.0, 30.0)));
|
||||
let restored = solvecraft_doc::format::read(&s.doc.to_json()).unwrap();
|
||||
let mut cold = Session { doc: restored.into(), ..Default::default() };
|
||||
cold.refresh();
|
||||
assert!(near(plane(&cold, "BridgePlane").origin, updated.origin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_on_a_3d_wire_moves_with_its_component() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name":"Part"}));
|
||||
let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut s, "sketch.options", json!({"sketch_3d":true}));
|
||||
let wire = run(&mut s, "sketch.line3d", json!({"points":[[0,0,10],[0,0,40]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "construct.plane.along_path", json!({"path":{"sketch":sketch,"curve":wire},"t":0.5,"name":"Section"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":"Part:1","translate":[50,20,0],"capture":true}));
|
||||
let p = plane(&s, "Section");
|
||||
assert!(near(p.origin, Vec3::new(50.0, 20.0, 25.0)), "{p:?}");
|
||||
assert!(parallel(p.normal(), Vec3::Z));
|
||||
}
|
||||
|
||||
/// Built from model geometry, they follow when the model changes: the box gets taller.
|
||||
#[test]
|
||||
fn construction_follows_the_model() {
|
||||
@@ -272,3 +313,116 @@ fn plane_perpendicular_to_a_plane_through_a_line() {
|
||||
// A line square to the plane has no single answer.
|
||||
assert!(s.execute("construct.plane.perpendicular", &json!({"plane": "XY", "line": {"axis": "Z"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_plane_aliases_refuse_without_document_or_history_changes() {
|
||||
for alias in ["Top", "Front", "Right", "XY", "XZ", "YZ"] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name":"height","expression":"19.05 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane":alias}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let made = run(&mut s, "construct.plane.offset", json!({"base":{"origin":[0,0,0],"normal":[0,0,1]},"offset":"height","name":"Lid"}));
|
||||
let id = made["feature"].as_u64().unwrap();
|
||||
legacy_plane_name(&mut s, id, alias);
|
||||
let before = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
for command in ["sketch.create", "inspect.section"] {
|
||||
let error = s.execute(command, &json!({"plane":format!(" {} ",alias.to_lowercase())})).unwrap_err();
|
||||
assert!(error.to_string().contains("ambiguous"), "{error}");
|
||||
assert!(error.to_string().contains(&format!("feature id {id}")), "{error}");
|
||||
let other = match alias {
|
||||
"Top" => "XY",
|
||||
"XY" => "Top",
|
||||
"Front" => "XZ",
|
||||
"XZ" => "Front",
|
||||
"Right" => "YZ",
|
||||
_ => "Right",
|
||||
};
|
||||
assert!(error.to_string().contains(&format!("use `{other}` for the origin plane")), "{error}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
assert!(s.section.is_none());
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane":id}));
|
||||
let inspected = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(inspected["plane"]["origin"], json!([0.0, 0.0, 19.05]));
|
||||
let cut = run(&mut s, "inspect.section", json!({"plane":id}));
|
||||
assert_eq!(cut["section"]["origin"], json!([0.0, 0.0, 19.05]));
|
||||
run(&mut s, "parameters.change", json!({"name":"height","expression":"25.4 mm"}));
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let old = run(&mut reopened, "sketch.inspect", json!({"sketch":"Sketch1"}));
|
||||
assert_eq!(old["plane"]["origin"], json!([0.0, 0.0, 0.0]), "saved origin reference is not reinterpreted");
|
||||
let linked = run(&mut reopened, "sketch.inspect", json!({"sketch":"Sketch2"}));
|
||||
assert_eq!(linked["plane"]["origin"], json!([0.0, 0.0, 25.4]), "disambiguated reference follows edits and reopen");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disambiguated_top_plane_pocket_survives_undo_and_cold_reopen() {
|
||||
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":[100,200]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":19.05,"name":"Plate"}));
|
||||
let top = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":19.05,"name":"Lid"}));
|
||||
legacy_plane_name(&mut s, top["feature"].as_u64().unwrap(), "Top");
|
||||
assert!(s.execute("sketch.create", &json!({"plane":"Top"})).unwrap_err().to_string().contains("ambiguous"));
|
||||
run(&mut s, "sketch.create", json!({"plane":top["feature"]}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[3,3],"p1":[97,30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "solid.extrude", json!({"distance":12.7,"direction":"negative","operation":"cut","name":"Pocket"}));
|
||||
let saved = s.doc.to_json();
|
||||
let expected = 100.0 * 200.0 * 19.05 - 94.0 * 27.0 * 12.7;
|
||||
let measured = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - expected).abs() < 1e-6, "{measured}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
let mut reopened = Session::new(solvecraft_doc::Document::from_json(&saved).unwrap());
|
||||
let measured = run(&mut reopened, "inspect.measure", json!({}));
|
||||
assert!((measured["total"]["volume_mm3"].as_f64().unwrap() - expected).abs() < 1e-6, "{measured}");
|
||||
}
|
||||
|
||||
/// A construction plane named like an origin plane, as an older saved design can hold.
|
||||
fn legacy_plane_name(s: &mut Session, id: u64, name: &str) {
|
||||
s.doc_mut().feature_mut(id).unwrap().name = name.to_string();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_planes_cannot_take_origin_plane_names() {
|
||||
let mut s = Session::default();
|
||||
let made = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":5,"name":"Lid"}));
|
||||
let id = made["feature"].as_u64().unwrap();
|
||||
for alias in ["XY", "XZ", "YZ", "Top", "Front", "Right", " top ", "xz"] {
|
||||
let before = s.doc.to_json();
|
||||
let history = s.undo.len();
|
||||
let created = s.execute("construct.plane.offset", &json!({"base":"XY","offset":10,"name":alias})).unwrap_err();
|
||||
assert!(created.to_string().contains("reserved for an origin plane"), "{created}");
|
||||
let renamed = s.execute("timeline.rename", &json!({"feature":id,"name":alias})).unwrap_err();
|
||||
assert!(renamed.to_string().contains("reserved for an origin plane"), "{renamed}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), history);
|
||||
}
|
||||
// Other features may still use the names; only a plane parameter could confuse them.
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "timeline.rename", json!({"feature":"Sketch1","name":"Top"}));
|
||||
run(&mut s, "timeline.rename", json!({"feature":id,"name":"Topside"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguity_error_names_the_other_origin_alias() {
|
||||
for (name, other) in [("Top", "XY"), ("XY", "Top"), ("Front", "XZ"), ("XZ", "Front"), ("Right", "YZ"), ("YZ", "Right")] {
|
||||
let mut s = Session::default();
|
||||
let made = run(&mut s, "construct.plane.offset", json!({"base":"XY","offset":5,"name":"Lid"}));
|
||||
legacy_plane_name(&mut s, made["feature"].as_u64().unwrap(), name);
|
||||
let error = s.execute("sketch.create", &json!({"plane":name})).unwrap_err().to_string();
|
||||
assert!(error.contains(&format!("use `{other}` for the origin plane")), "{error}");
|
||||
// The suggested alias is unambiguous and reaches the origin plane.
|
||||
run(&mut s, "sketch.create", json!({"plane":other}));
|
||||
let inspected = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert_eq!(inspected["plane"]["origin"], json!([0.0, 0.0, 0.0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ pub struct Run {
|
||||
pub unsupported: Vec<(String, String)>,
|
||||
/// Steps whose generated parameters a command refused.
|
||||
pub rejected: usize,
|
||||
pub boolean_fallbacks: std::collections::BTreeMap<solvecraft_kernel::BooleanStrategy, usize>,
|
||||
pub boolean_reports_truncated: bool,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
@@ -182,7 +184,7 @@ fn base(r: &mut Rng) -> Step {
|
||||
("sketch.line", json!({"points": pts})),
|
||||
("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})),
|
||||
("sketch.finish", json!({})),
|
||||
("Extrude", json!({"distance": d})),
|
||||
("solid.extrude", json!({"distance": d})),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -196,7 +198,7 @@ fn base(r: &mut Rng) -> Step {
|
||||
("sketch.create", json!({"plane": "XZ"})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})),
|
||||
("sketch.finish", json!({})),
|
||||
("Revolve", json!({"axis": "Z", "angle": angle})),
|
||||
("solid.revolve", json!({"axis": "Z", "angle": angle})),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -289,7 +291,15 @@ pub fn run(seed: u64, max_steps: usize) -> Run {
|
||||
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
|
||||
let mut r = Rng::new(seed);
|
||||
let mut s = Session::default();
|
||||
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
|
||||
let mut run = Run {
|
||||
seed,
|
||||
steps: Vec::new(),
|
||||
outcome: Outcome::ok(),
|
||||
unsupported: Vec::new(),
|
||||
rejected: 0,
|
||||
boolean_fallbacks: Default::default(),
|
||||
boolean_reports_truncated: false,
|
||||
};
|
||||
let mut last_feature: Option<String> = None;
|
||||
let n = 2 + r.below(max_steps.max(2) - 1);
|
||||
let mut tries = 0;
|
||||
@@ -314,6 +324,12 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
|
||||
continue;
|
||||
}
|
||||
let kind = st.kind;
|
||||
for result in s.model.results.iter().skip(before).filter(|result| result.error.is_none()) {
|
||||
run.boolean_reports_truncated |= result.boolean_diagnostics.truncated;
|
||||
for event in &result.boolean_diagnostics.events {
|
||||
*run.boolean_fallbacks.entry(event.strategy).or_default() += 1;
|
||||
}
|
||||
}
|
||||
run.steps.push(st);
|
||||
if out.kind != "ok" {
|
||||
run.outcome = out;
|
||||
@@ -329,14 +345,19 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
|
||||
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
|
||||
/// that isn't ok (or the last step's, ok).
|
||||
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
|
||||
let (index, outcome, _) = replay_session(steps);
|
||||
(index, outcome)
|
||||
}
|
||||
|
||||
fn replay_session(steps: &[Step]) -> (usize, Outcome, Session) {
|
||||
let mut s = Session::default();
|
||||
for (i, st) in steps.iter().enumerate() {
|
||||
let out = apply(&mut s, st);
|
||||
if out.kind != "ok" {
|
||||
return (i, out);
|
||||
return (i, out, s);
|
||||
}
|
||||
}
|
||||
(steps.len().saturating_sub(1), Outcome::ok())
|
||||
(steps.len().saturating_sub(1), Outcome::ok(), s)
|
||||
}
|
||||
|
||||
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
|
||||
@@ -367,6 +388,14 @@ pub fn minimise(run: &Run) -> Run {
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, outcome) = replay(&steps);
|
||||
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
|
||||
let (_, outcome, session) = replay_session(&steps);
|
||||
let mut boolean_fallbacks = std::collections::BTreeMap::new();
|
||||
let mut boolean_reports_truncated = false;
|
||||
for result in &session.model.results {
|
||||
boolean_reports_truncated |= result.boolean_diagnostics.truncated;
|
||||
for event in &result.boolean_diagnostics.events {
|
||||
*boolean_fallbacks.entry(event.strategy).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0, boolean_fallbacks, boolean_reports_truncated }
|
||||
}
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! Legacy migration: the command ids SolveCraft used before 2026-10-09, when many commands
|
||||
//! carried the id of the matching command in another CAD program, and the dotted ids that
|
||||
//! replaced them. This is the one place the old ids live. Old scripts, recipes, control-channel
|
||||
//! calls, saved shortcuts and toolbar layouts keep working: `find_command` and the preference
|
||||
//! loaders map an old id through `current_id`. `solvecraft-tools/rename_ids.sh` converts a
|
||||
//! worktree with this same table.
|
||||
|
||||
/// (old id, current id).
|
||||
pub const LEGACY_IDS: &[(&str, &str)] = &[
|
||||
("AsBuiltPositionsCmd", "component.revert_position"),
|
||||
("FusionCreateComponentsFromBodiesCommand", "component.from_bodies"),
|
||||
("FusionCreateNewComponentCommand", "component.create"),
|
||||
("SnapshotCmd", "component.capture_position"),
|
||||
("FusionShowDesignConfigPanelCmd", "config.table"),
|
||||
("FusionStartDesignConfigModeCmd", "config.configure"),
|
||||
("FusionComputeAllCommand", "timeline.compute_all"),
|
||||
("FusionDeleteCommand", "timeline.delete"),
|
||||
("FusionRenameTimelineEntryCommand", "timeline.rename"),
|
||||
("RedoCommand", "edit.redo"),
|
||||
("UndoCommand", "edit.undo"),
|
||||
("timeline.rollTo", "timeline.roll_to"),
|
||||
("AlignCmd", "solid.align"),
|
||||
("EmbossCmd", "solid.emboss"),
|
||||
("FusionReplaceFaceCommand", "solid.replace_face"),
|
||||
("FusionRibCommand", "solid.rib"),
|
||||
("FusionWebCommand", "solid.web"),
|
||||
("PrimitiveCoil", "solid.coil"),
|
||||
("SoftDeleteCommand", "solid.remove"),
|
||||
("ConstructionPlaneAtAngleCommand", "construct.plane.at_angle"),
|
||||
("ConstructionPlaneOffsetFromPlaneCommand", "construct.plane.offset"),
|
||||
("Extrude", "solid.extrude"),
|
||||
("FusionChamferCommand", "solid.chamfer"),
|
||||
("FusionCombineCommand", "solid.combine"),
|
||||
("FusionDraftCommand", "solid.draft"),
|
||||
("FusionFilletEdgesCommand", "solid.fillet"),
|
||||
("FusionHoleCommand", "solid.hole"),
|
||||
("FusionMoveCommand", "solid.move"),
|
||||
("FusionOffsetFacesCommand", "solid.offset_face"),
|
||||
("FusionPressPullCommand", "solid.press_pull"),
|
||||
("FusionShellBodyCommand", "solid.shell"),
|
||||
("FusionSplitBodyCommand", "solid.split_body"),
|
||||
("FusionThreadCommand", "solid.thread"),
|
||||
("MirrorCommand", "solid.mirror"),
|
||||
("ModifyScale", "solid.scale"),
|
||||
("PatternCircular", "solid.pattern.circular"),
|
||||
("PatternOnPath", "solid.pattern.path"),
|
||||
("PatternRectangular", "solid.pattern.rectangular"),
|
||||
("PrimitiveBox", "solid.box"),
|
||||
("PrimitiveCylinder", "solid.cylinder"),
|
||||
("PrimitivePipe", "solid.pipe"),
|
||||
("PrimitiveSphere", "solid.sphere"),
|
||||
("PrimitiveTorus", "solid.torus"),
|
||||
("Revolve", "solid.revolve"),
|
||||
("SolidLoft", "solid.loft"),
|
||||
("StockModelCommand", "solid.bounding_solid"),
|
||||
("Sweep", "solid.sweep"),
|
||||
("ExportCommand", "file.export"),
|
||||
("FusionImportCommandFromToolbar", "file.insert_step"),
|
||||
("FusionSaveAsSTLCommand", "file.save_mesh"),
|
||||
("NewDocumentCommand", "file.new"),
|
||||
("ParaMeshInsertAlignCommand", "file.insert_mesh"),
|
||||
("SaveDocumentAsCommand", "file.save_as"),
|
||||
("SaveDocumentCommand", "file.save"),
|
||||
("AppearanceCommand", "appearance.assign"),
|
||||
("MeasureCommand", "inspect.measure"),
|
||||
("PhysicalMaterialCommand", "material.assign"),
|
||||
("FusionMotionRelationshipCommand", "joint.motion_link"),
|
||||
("FusionMoveJointsCommand", "joint.drive"),
|
||||
("InterferenceCheckCommand", "inspect.interference"),
|
||||
("JointAsBuiltCmd", "joint.as_built"),
|
||||
("JointAssembleCmdNew", "joint.create"),
|
||||
("JointOrigin", "joint.origin"),
|
||||
("RigidGroupCmd", "joint.rigid_group"),
|
||||
("ContactSetCmd", "contact.create"),
|
||||
("DisableAllContactCmd", "contact.disable_all"),
|
||||
("EnableAllContactCmd", "contact.enable_all"),
|
||||
("EnableContactSetsCmd", "contact.enable_sets"),
|
||||
("FusionMotionStudyCommand", "motion.study"),
|
||||
("ChangeParameterCommand", "parameters.change"),
|
||||
("FusionFastenersCommand", "parts.fastener"),
|
||||
("FusionAssignPlasticRuleCommand", "plastic.assign_rule"),
|
||||
("FusionBossCommand", "plastic.boss"),
|
||||
("FusionLipCommand", "plastic.lip"),
|
||||
("FusionManagePlasticRuleCommand", "plastic.manage_rules"),
|
||||
("FusionRestCommand", "plastic.rest"),
|
||||
("FusionSnapFitCommand", "plastic.snap_fit"),
|
||||
("FusionHalfSectionViewCommand", "inspect.section"),
|
||||
("ConvertToSheetMetalCmd", "sheet.convert"),
|
||||
("FusionSheetMetalFlangeCommand", "sheet.flange"),
|
||||
("FusionSheetMetalFlatPatternCmd", "sheet.flat_pattern"),
|
||||
("FusionSheetMetalHemFlangeCommand", "sheet.hem"),
|
||||
("FusionSheetMetalRulesCommand", "sheet.manage_rules"),
|
||||
("FusionSheetmetalUnfoldCommand", "sheet.unfold"),
|
||||
("SheetMetalFoldCmd", "sheet.fold"),
|
||||
("FusionAddCanvasCommand", "canvas.insert"),
|
||||
("FusionAddEditDecalCommand", "canvas.decal"),
|
||||
("ConstraintSmooth", "sketch.constraint.curvature"),
|
||||
("SketchAutoConstrainAndFinish", "sketch.finish_auto_constrain"),
|
||||
("SketchAutoConstraintAndDimCmd", "sketch.auto_constrain"),
|
||||
("SketchAutoConstraintAndDimFromDatumCmd", "sketch.auto_constrain_from_datum"),
|
||||
("SketchConstrainer", "sketch.constrainer"),
|
||||
("SketchPolygonConstraintCmd", "sketch.constraint.polygon"),
|
||||
("ArcTangent", "sketch.arc.tangent"),
|
||||
("BlendG1CurveSketchCmd", "sketch.blend_curve"),
|
||||
("CircleElipse", "sketch.ellipse"),
|
||||
("CircleTanTanRadius", "sketch.circle.two_tangent"),
|
||||
("CircleThreeTangent", "sketch.circle.three_tangent"),
|
||||
("ConicCurveCmd", "sketch.conic"),
|
||||
("DrawCVMSpline3D", "sketch.spline.control_point"),
|
||||
("DrawCVMSpline5D", "sketch.spline.control_point_5"),
|
||||
("DrawSpline", "sketch.spline.fit_point"),
|
||||
("MTextCmd", "sketch.text"),
|
||||
("ShapeArcSlotCenterTwoPoint", "sketch.slot.arc_center"),
|
||||
("ShapeArcSlotThreePoint", "sketch.slot.arc_three_point"),
|
||||
("ShapeSlotCenterPoint", "sketch.slot.center_point"),
|
||||
("SketchMidpointLine", "sketch.line.midpoint"),
|
||||
("ImportDxfFileCommand", "sketch.insert_dxf"),
|
||||
("SketchImportSVG", "sketch.insert_svg"),
|
||||
("FusionCenterOfMassCommand", "inspect.center_of_mass"),
|
||||
("FusionCurvatureCombAnalysisCommand", "inspect.curvature_comb"),
|
||||
("FusionIsoCurveAnalysisCommand", "inspect.isocurve"),
|
||||
("FusionMinimumRadiusAnalysisCommand", "inspect.minimum_radius"),
|
||||
("BreakSketchCmd", "sketch.break"),
|
||||
("ChamferSketchDistanceAngle", "sketch.chamfer.distance_angle"),
|
||||
("ChamferSketchDistanceDistance", "sketch.chamfer.two_distance"),
|
||||
("ChamferSketchEqualDistance", "sketch.chamfer.equal_distance"),
|
||||
("CircularSketchPatternCommand", "sketch.pattern.circular"),
|
||||
("ExtendSketchCmd", "sketch.extend"),
|
||||
("FilletSketchCmd", "sketch.fillet"),
|
||||
("MirrorSketchCommand", "sketch.mirror"),
|
||||
("Offset", "sketch.offset"),
|
||||
("RectangularSketchPatternCommand", "sketch.pattern.rectangular"),
|
||||
("SketchScaleCmd", "sketch.scale"),
|
||||
("TrimSketchCmd", "sketch.trim"),
|
||||
("FitCurvesToSectionCommand", "sketch.fit_curves_to_section"),
|
||||
("Include3DGeometry", "sketch.include_3d"),
|
||||
("IntersectCmd", "sketch.intersect"),
|
||||
("IntersectionCurve", "sketch.intersection_curve"),
|
||||
("ProjectNewCmd", "sketch.project"),
|
||||
("ProjectToSurface", "sketch.project_to_surface"),
|
||||
("SketchIsoparametricCurve", "sketch.isoparametric_curve"),
|
||||
("SpunProfileCmd", "sketch.spun_profile"),
|
||||
("ArcCenterTwoPoint", "sketch.arc.center_point"),
|
||||
("ArcThreePoint", "sketch.arc.three_point"),
|
||||
("CircleCenterRadius", "sketch.circle.center"),
|
||||
("CircleThreePoint", "sketch.circle.three_point"),
|
||||
("CircleTwoPoint", "sketch.circle.two_point"),
|
||||
("ConstraintCoincident", "sketch.constraint.coincident"),
|
||||
("ConstraintCollinear", "sketch.constraint.collinear"),
|
||||
("ConstraintConcentric", "sketch.constraint.concentric"),
|
||||
("ConstraintEqual", "sketch.constraint.equal"),
|
||||
("ConstraintFix", "sketch.constraint.fix"),
|
||||
("ConstraintHorizontalVertical", "sketch.constraint.horizontal_vertical"),
|
||||
("ConstraintMidPoint", "sketch.constraint.midpoint"),
|
||||
("ConstraintParallel", "sketch.constraint.parallel"),
|
||||
("ConstraintPerpendicular", "sketch.constraint.perpendicular"),
|
||||
("ConstraintSymmetry", "sketch.constraint.symmetry"),
|
||||
("ConstraintTangent", "sketch.constraint.tangent"),
|
||||
("DrawPoint", "sketch.point"),
|
||||
("DrawPolyline", "sketch.line"),
|
||||
("ShapePolygonCircumscribed", "sketch.polygon.circumscribed"),
|
||||
("ShapePolygonEdge", "sketch.polygon.edge"),
|
||||
("ShapePolygonInscribed", "sketch.polygon.inscribed"),
|
||||
("ShapeRectangleCenter", "sketch.rectangle.center"),
|
||||
("ShapeRectangleThreePoint", "sketch.rectangle.three_point"),
|
||||
("ShapeRectangleTwoPoint", "sketch.rectangle.two_point"),
|
||||
("ShapeSlotCenterToCenter", "sketch.slot.center_to_center"),
|
||||
("ShapeSlotOverall", "sketch.slot.overall"),
|
||||
("SketchActivate", "sketch.edit"),
|
||||
("SketchCreate", "sketch.create"),
|
||||
("SketchDimension", "sketch.dimension"),
|
||||
("SketchStop", "sketch.finish"),
|
||||
("FusionAccessibilityAnalysisCommand", "inspect.accessibility"),
|
||||
("FusionCurvatureMapAnalysisCommand", "inspect.curvature_map"),
|
||||
("FusionDraftAnalysisCommand", "inspect.draft"),
|
||||
("FusionEnvironmentMapAnalysisCommand", "inspect.environment_map"),
|
||||
("FusionZebraAnalysisCommand", "inspect.zebra"),
|
||||
("FusionExtendCommand", "surface.extend"),
|
||||
("FusionSurfacePatchCommand", "surface.patch"),
|
||||
("FusionSurfaceStitchCommand", "surface.stitch"),
|
||||
("FusionSurfaceThickenCommand", "surface.thicken"),
|
||||
("FusionSurfaceTrimCommand", "surface.trim"),
|
||||
("FusionSplitFaceCommand", "solid.split_face"),
|
||||
("ConstructionAxisThroughCylinderCommand", "construct.axis.through_cylinder"),
|
||||
("ConstructionAxisNormalToFaceCommand", "construct.axis.normal_to_face"),
|
||||
("ConstructionAxisFromTwoPlanesCommand", "construct.axis.two_planes"),
|
||||
("ConstructionAxisThroughTwoPointsCommand", "construct.axis.two_points"),
|
||||
("ConstructionAxisThroughEdgeCommand", "construct.axis.edge"),
|
||||
("ConstructionPointFromPointCommand", "construct.point.vertex"),
|
||||
("ConstructionPointThroughTwoEdgesCommand", "construct.point.two_edges"),
|
||||
("ConstructionPointFromThreePlanesCommand", "construct.point.three_planes"),
|
||||
("ConstructionPointFromCircleOrSphereCommand", "construct.point.center"),
|
||||
("ConstructionPointAtEdgeAndPlaneCommand", "construct.point.edge_plane"),
|
||||
("ConstructionPointAlongPathCommand", "construct.point.along_path"),
|
||||
("ConstructionMidPlaneCommand", "construct.plane.midplane"),
|
||||
("ConstructionPlaneFromTwoEdgesCommand", "construct.plane.two_edges"),
|
||||
("ConstructionPlaneFromThreePointsCommand", "construct.plane.three_points"),
|
||||
("ConstructionTangentPlaneCommand", "construct.plane.tangent"),
|
||||
("ConstructionPlaneAlongPathCommand", "construct.plane.along_path"),
|
||||
];
|
||||
|
||||
/// The current id for a command id that may be a legacy one (unchanged otherwise).
|
||||
pub fn current_id(id: &str) -> &str {
|
||||
LEGACY_IDS.iter().find(|(old, _)| old.eq_ignore_ascii_case(id)).map(|(_, new)| *new).unwrap_or(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_legacy_id_names_a_command() {
|
||||
let unknown: Vec<&str> =
|
||||
LEGACY_IDS.iter().filter(|(_, new)| crate::find_command(new).is_none_or(|c| c.id != *new)).map(|(_, new)| *new).collect();
|
||||
// The construction set and Split Face are on a branch not yet merged when the table was made.
|
||||
let pending = ["solid.split_face"];
|
||||
let unknown: Vec<&str> = unknown
|
||||
.into_iter()
|
||||
.filter(|n| {
|
||||
!pending.contains(n)
|
||||
&& !n.starts_with("construct.axis")
|
||||
&& !n.starts_with("construct.point")
|
||||
&& ![
|
||||
"construct.plane.midplane",
|
||||
"construct.plane.two_edges",
|
||||
"construct.plane.three_points",
|
||||
"construct.plane.tangent",
|
||||
"construct.plane.along_path",
|
||||
]
|
||||
.contains(n)
|
||||
})
|
||||
.collect();
|
||||
assert!(unknown.is_empty(), "{unknown:?}");
|
||||
assert_eq!(crate::find_command("SketchCreate").map(|c| c.id), Some("sketch.create"));
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -10,7 +10,6 @@
|
||||
pub mod cmd;
|
||||
pub mod frames;
|
||||
pub mod fuzz;
|
||||
pub mod legacy_ids;
|
||||
pub mod licences;
|
||||
pub mod occurrences;
|
||||
pub mod params;
|
||||
@@ -107,6 +106,15 @@ pub enum Sel {
|
||||
SketchPoint {
|
||||
id: String,
|
||||
},
|
||||
/// Geometry of a different sketch, retained for projection preselection.
|
||||
ExternalSketchCurve {
|
||||
sketch: u64,
|
||||
id: String,
|
||||
},
|
||||
ExternalSketchPoint {
|
||||
sketch: u64,
|
||||
id: String,
|
||||
},
|
||||
/// A constraint or dimension (by id) of the active sketch.
|
||||
SketchConstraint {
|
||||
id: String,
|
||||
@@ -150,6 +158,28 @@ pub struct Preview {
|
||||
pub after: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Prepared world geometry depends on design/model identity and placement context, not
|
||||
/// selection or the view revision. Retained Arcs prevent pointer reuse after a design closes.
|
||||
#[derive(Clone)]
|
||||
struct WorldCache {
|
||||
doc: Arc<Document>,
|
||||
model: Arc<solvecraft_doc::ModelState>,
|
||||
pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
active_component: u64,
|
||||
active_occurrence_path: Option<Vec<u64>>,
|
||||
world: Arc<solvecraft_doc::ModelState>,
|
||||
}
|
||||
|
||||
impl WorldCache {
|
||||
fn matches(&self, session: &Session, model: &Arc<solvecraft_doc::ModelState>) -> bool {
|
||||
Arc::ptr_eq(&self.doc, &session.doc)
|
||||
&& Arc::ptr_eq(&self.model, model)
|
||||
&& self.pending_moves == session.pending_moves
|
||||
&& self.active_component == session.active_component
|
||||
&& self.active_occurrence_path == session.active_occurrence_path
|
||||
}
|
||||
}
|
||||
|
||||
/// An editing session on one design.
|
||||
pub struct Session {
|
||||
pub doc: Arc<Document>,
|
||||
@@ -173,7 +203,7 @@ pub struct Session {
|
||||
pub active_occurrence_path: Option<Vec<u64>>,
|
||||
/// Occurrence moves not captured yet (Capture Position keeps them, a recompute drops them).
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solvecraft_doc::Mat>,
|
||||
world_cache: std::sync::Mutex<Option<(u64, Arc<solvecraft_doc::ModelState>)>>,
|
||||
world_cache: std::sync::Mutex<Option<WorldCache>>,
|
||||
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
|
||||
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
|
||||
pub clipboard: Vec<solvecraft_doc::Feature>,
|
||||
@@ -308,10 +338,10 @@ impl Session {
|
||||
return st;
|
||||
}
|
||||
if let Ok(c) = self.world_cache.lock()
|
||||
&& let Some((rev, w)) = c.as_ref()
|
||||
&& *rev == self.revision
|
||||
&& let Some(cached) = c.as_ref()
|
||||
&& cached.matches(self, &st)
|
||||
{
|
||||
return w.clone();
|
||||
return cached.world.clone();
|
||||
}
|
||||
let mut w = if self.pending_moves.is_empty() {
|
||||
solvecraft_doc::world_state(&self.doc, &st)
|
||||
@@ -350,7 +380,14 @@ impl Session {
|
||||
}
|
||||
let w = Arc::new(w);
|
||||
if let Ok(mut c) = self.world_cache.lock() {
|
||||
*c = Some((self.revision, w.clone()));
|
||||
*c = Some(WorldCache {
|
||||
doc: self.doc.clone(),
|
||||
model: st,
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
world: w.clone(),
|
||||
});
|
||||
}
|
||||
w
|
||||
}
|
||||
@@ -503,7 +540,7 @@ impl Session {
|
||||
active_component: self.active_component,
|
||||
active_occurrence_path: self.active_occurrence_path.clone(),
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
world_cache: Default::default(),
|
||||
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
@@ -519,12 +556,6 @@ impl Session {
|
||||
pub fn invalidate_view(&mut self, previous_revision: u64) {
|
||||
let old = self.revision;
|
||||
self.revision = old.max(previous_revision) + 1;
|
||||
if let Ok(mut cache) = self.world_cache.lock()
|
||||
&& let Some((revision, _)) = cache.as_mut()
|
||||
&& *revision == old
|
||||
{
|
||||
*revision = self.revision;
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit commands evaluated on a scratch copy without losing this session's file,
|
||||
@@ -640,6 +671,8 @@ mod delete_face_tests;
|
||||
#[cfg(test)]
|
||||
mod extent_tests;
|
||||
#[cfg(test)]
|
||||
mod naming_history_tests;
|
||||
#[cfg(test)]
|
||||
mod pattern_suppress_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_curves_tests;
|
||||
@@ -651,3 +684,5 @@ mod split_face_tests;
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod thread_tests;
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Persistent names follow the kernel's exact face history: a reference to a face or edge finds
|
||||
//! the face it was made on after upstream edits that split, merge, mirror, shrink or move what it
|
||||
//! names, or says it could not. It never quietly attaches to another face, which is what finding
|
||||
//! names again by sampling geometry could do.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn id(s: &Session, name: &str) -> u64 {
|
||||
s.doc.features.iter().rev().find(|f| f.name == name).map(|f| f.id).unwrap_or_else(|| panic!("feature {name}"))
|
||||
}
|
||||
|
||||
fn warning(s: &Session, name: &str) -> Option<String> {
|
||||
s.model.result(id(s, name)).and_then(|r| r.warning.clone())
|
||||
}
|
||||
|
||||
fn error(s: &Session, name: &str) -> Option<String> {
|
||||
s.model.result(id(s, name)).and_then(|r| r.error.clone())
|
||||
}
|
||||
|
||||
/// Whether any body holds `p`.
|
||||
fn solid_at(s: &Session, p: [f64; 3]) -> bool {
|
||||
s.model.state().bodies.iter().any(|b| b.body.contains(Vec3::new(p[0], p[1], p[2])))
|
||||
}
|
||||
|
||||
/// Which body holds `p`.
|
||||
fn body_at(s: &Session, p: [f64; 3]) -> Option<String> {
|
||||
s.model.state().bodies.iter().find(|b| b.body.contains(Vec3::new(p[0], p[1], p[2]))).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
fn names(s: &Session, body: &str) -> Vec<(String, bool)> {
|
||||
let state = s.model.state();
|
||||
let b = state.body(body).unwrap_or_else(|| panic!("body {body}"));
|
||||
let names = solvecraft_doc::naming::face_names(b);
|
||||
names.iter().enumerate().map(|(i, n)| (n.clone(), solvecraft_doc::naming::face_name_exact(b, i))).collect()
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 10 block ("Block": sketch lines l1 bottom, l2 right, l3 top, l4 left).
|
||||
fn block(s: &mut Session) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
|
||||
}
|
||||
|
||||
/// A slot across the block from x = `slot_x` to `slot_x + slot_w`, from z = `floor` up through
|
||||
/// the top: it splits the top (and, cut to the floor, the block) in two.
|
||||
fn slot(s: &mut Session, floor: f64) {
|
||||
run(s, "parameters.add", json!({"name": "slot_x", "expression": "15 mm"}));
|
||||
run(s, "parameters.add", json!({"name": "slot_w", "expression": "10 mm"}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "SlotSketch"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [15, -5], "p1": [25, 35]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "slot_x"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "slot_w"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let mut p = json!({"sketch": "SlotSketch", "distance": 12, "operation": "cut", "name": "Slot"});
|
||||
if floor > 0.0 {
|
||||
p["start_offset"] = json!(floor);
|
||||
}
|
||||
run(s, "solid.extrude", p);
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, name: &str, expression: &str) {
|
||||
run(s, "parameters.change", json!({"name": name, "expression": expression}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_built_from_a_profile_are_named_exactly() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
slot(&mut s, 5.0);
|
||||
let block = id(&s, "Block");
|
||||
let cut = id(&s, "Slot");
|
||||
let n = names(&s, "Body1");
|
||||
assert!(n.iter().all(|(_, exact)| *exact), "{n:?}");
|
||||
let has = |want: &str| n.iter().any(|(x, _)| x == want);
|
||||
// The top is in two pieces, each told apart by the walls only it touches.
|
||||
assert!(has(&format!("F{block}:top#[F{block}:side:l4,F{cut}:side:l4]")), "{n:?}");
|
||||
assert!(has(&format!("F{block}:top#[F{block}:side:l2,F{cut}:side:l2]")), "{n:?}");
|
||||
// The slot's floor is its start cap (above the sketch plane), its walls its sketch curves.
|
||||
for want in [format!("F{cut}:top"), format!("F{cut}:side:l2"), format!("F{cut}:side:l4"), format!("F{block}:start")] {
|
||||
assert!(has(&want), "{want} in {n:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upstream_cut_that_splits_a_face_keeps_the_edge_that_bounded_it() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
|
||||
let block = id(&s, "Block");
|
||||
let round = s.doc.find_feature("Round").cloned().unwrap();
|
||||
assert_eq!(round.edge_names, vec![format!("F{block}:side:l2|F{block}:top")]);
|
||||
// Upstream of the fillet, a slot splits the top it bounds.
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Block"}));
|
||||
slot(&mut s, 5.0);
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
assert_eq!(error(&s, "Round"), None);
|
||||
// Only one edge runs between the right wall and a piece of the top: the same edge.
|
||||
assert_eq!(warning(&s, "Round"), None);
|
||||
let strip = (4.0 - std::f64::consts::PI) * 30.0;
|
||||
let v = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!((v - (12000.0 - 10.0 * 30.0 * 5.0 - strip)).abs() < 0.05, "{v}");
|
||||
assert!(!solid_at(&s, [39.9, 15.0, 9.9]), "the right top edge is rounded");
|
||||
assert!(solid_at(&s, [0.1, 15.0, 9.9]), "the left one is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_piece_keeps_its_identity_when_the_cut_that_made_it_moves_or_shrinks() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
slot(&mut s, 5.0);
|
||||
let block = id(&s, "Block");
|
||||
let cut = id(&s, "Slot");
|
||||
// Raise the piece right of the slot, picked near the slot.
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[27, 15, 10]], "distance": 2, "name": "Raise"}));
|
||||
let raise = s.doc.find_feature("Raise").cloned().unwrap();
|
||||
assert_eq!(raise.face_names, vec![format!("F{block}:top#[F{block}:side:l2,F{cut}:side:l2]")]);
|
||||
assert!(solid_at(&s, [27.0, 15.0, 11.0]) && solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [10.0, 15.0, 11.0]));
|
||||
// The slot moves right past the picked point, which now lies on the other piece: the name,
|
||||
// not the point, says which piece was raised.
|
||||
change(&mut s, "slot_x", "28 mm");
|
||||
assert_eq!(error(&s, "Raise"), None);
|
||||
assert_eq!(warning(&s, "Raise"), None);
|
||||
assert!(solid_at(&s, [39.0, 15.0, 11.0]), "the right piece is still the raised one");
|
||||
assert!(!solid_at(&s, [27.0, 15.0, 11.0]) && !solid_at(&s, [10.0, 15.0, 11.0]), "the left piece is not raised");
|
||||
// Shrunk to a sliver, the slot still splits the top: the same piece, exactly.
|
||||
change(&mut s, "slot_w", "0.05 mm");
|
||||
assert_eq!(warning(&s, "Raise"), None);
|
||||
assert!(solid_at(&s, [39.0, 15.0, 11.0]) && !solid_at(&s, [27.0, 15.0, 11.0]));
|
||||
// Gone altogether (suppressed), the piece is the whole top again: said, not guessed.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Slot", "suppressed": true}));
|
||||
let w = warning(&s, "Raise").unwrap_or_default();
|
||||
assert!(w.contains("split") || w.contains("no longer exists"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_face_merged_into_another_answers_to_its_name_and_says_so() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "5 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [40, 0], "p1": [60, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": "boss_h", "operation": "join", "name": "Boss"}));
|
||||
let boss = id(&s, "Boss");
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[50, 15, 5]], "distance": 1, "name": "Raise"}));
|
||||
let raise = s.doc.find_feature("Raise").cloned().unwrap();
|
||||
assert_eq!(raise.face_names, vec![format!("F{boss}:top")]);
|
||||
assert!(solid_at(&s, [50.0, 15.0, 5.5]) && !solid_at(&s, [50.0, 15.0, 6.5]));
|
||||
// As tall as the block, the boss's top and the block's are one face: the reference finds
|
||||
// it and says it was merged.
|
||||
change(&mut s, "boss_h", "10 mm");
|
||||
assert_eq!(error(&s, "Raise"), None);
|
||||
let w = warning(&s, "Raise").unwrap_or_default();
|
||||
assert!(w.contains("merged"), "{w}");
|
||||
assert!(solid_at(&s, [50.0, 15.0, 10.5]) && solid_at(&s, [20.0, 15.0, 10.5]), "the merged top is raised as one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_twins_keep_their_own_edges() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "parameters.add", json!({"name": "hole_x", "expression": "10 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [10, 15], "radius": 3, "id": "c"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "c.center"], "type": "horizontal", "value": "hole_x"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "c.center"], "type": "vertical", "value": "15 mm"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 10, "operation": "cut", "name": "Hole"}));
|
||||
run(&mut s, "solid.mirror", json!({"features": ["Hole"], "plane": {"origin": [20, 0, 0], "normal": [1, 0, 0]}, "name": "Twin"}));
|
||||
let twin = id(&s, "Twin");
|
||||
// Chamfer the twin's rim (at x = 30) only.
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[33, 15, 10]], "distance": 1, "name": "Bevel"}));
|
||||
let bevel = s.doc.find_feature("Bevel").cloned().unwrap();
|
||||
assert!(bevel.edge_names.iter().all(|n| n.contains(&format!("@F{twin}.1"))), "{:?}", bevel.edge_names);
|
||||
let rim = |c: f64| [c + 3.5, 15.0, 9.8];
|
||||
assert!(!solid_at(&s, rim(30.0)) && solid_at(&s, rim(10.0)));
|
||||
// The holes move toward each other: the chamfer's picked point is now off both rims, and
|
||||
// the twin is the hole nearer it than its source is. Its name says which.
|
||||
change(&mut s, "hole_x", "14 mm");
|
||||
assert_eq!(error(&s, "Bevel"), None);
|
||||
assert_eq!(warning(&s, "Bevel"), None);
|
||||
assert!(!solid_at(&s, rim(26.0)), "the twin's rim is chamfered");
|
||||
assert!(solid_at(&s, rim(14.0)), "its source's is not");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_a_cut_body_keep_their_body_names_when_the_cut_moves() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
slot(&mut s, 0.0);
|
||||
let right = body_at(&s, [39.0, 15.0, 5.0]).unwrap();
|
||||
let left = body_at(&s, [1.0, 15.0, 5.0]).unwrap();
|
||||
assert_ne!(right, left);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 2, "name": "Round"}));
|
||||
for x in ["5 mm", "22 mm", "15 mm", "2 mm"] {
|
||||
change(&mut s, "slot_x", x);
|
||||
assert_eq!(body_at(&s, [39.0, 15.0, 5.0]), Some(right.clone()), "slot at {x}");
|
||||
assert_eq!(body_at(&s, [1.0, 15.0, 5.0]), Some(left.clone()), "slot at {x}");
|
||||
assert_eq!(error(&s, "Round"), None, "slot at {x}");
|
||||
assert_eq!(warning(&s, "Round"), None, "slot at {x}");
|
||||
assert!(!solid_at(&s, [39.9, 15.0, 9.9]), "slot at {x}");
|
||||
// Faces of the pieces, of one face cut in two, are told apart across the bodies.
|
||||
let all: Vec<String> = [&right, &left].iter().flat_map(|b| names(&s, b)).map(|(n, _)| n).collect();
|
||||
let unique: std::collections::BTreeSet<&String> = all.iter().collect();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_of_a_body_without_history_is_found_by_geometry_and_says_so_once_it_moves() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "len", "expression": "40 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "len"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10, "name": "Block"}));
|
||||
// Replacing the top by a plane: an operation that reports no history.
|
||||
run(&mut s, "solid.replace_face", json!({"faces": [[20, 15, 10]], "target": {"origin": [0, 0, 12], "normal": [0, 0, 1]}, "name": "Lift"}));
|
||||
let state = s.model.state();
|
||||
let body = state.bodies.first().unwrap();
|
||||
assert!((0..solvecraft_doc::naming::face_names(body).len()).all(|i| !solvecraft_doc::naming::face_name_exact(body, i)));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 12]], "radius": 2, "name": "Round"}));
|
||||
// Where it was picked, the pick and the name agree: nothing to say.
|
||||
assert_eq!(warning(&s, "Round"), None);
|
||||
// Moved away from the pick, only geometry found it: the fillet says so.
|
||||
change(&mut s, "len", "50 mm");
|
||||
assert_eq!(error(&s, "Round"), None);
|
||||
let w = warning(&s, "Round").unwrap_or_default();
|
||||
assert!(w.contains("found by geometry, not exact history"), "{w}");
|
||||
assert!(!solid_at(&s, [49.9, 15.0, 11.9]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sketch_on_a_certified_face_survives_a_fillet_and_a_cut_upstream_of_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "h", "expression": "10 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "h", "name": "Block"}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 0, 5]], "radius": 3, "name": "Corner"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "H"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [8, 8], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "H", "distance": 20, "operation": "cut", "name": "Hole"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [25, 18, 10]}, "name": "Top"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [25, 18], "radius": 3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": 4, "operation": "join", "name": "Peg"}));
|
||||
let top = |s: &Session| s.model.state().bodies.iter().map(|b| b.mesh().bounds().max.z).fold(f64::MIN, f64::max);
|
||||
assert!((top(&s) - 14.0).abs() < 1e-6);
|
||||
let state = s.model.state();
|
||||
let body = state.bodies.first().unwrap();
|
||||
assert!((0..solvecraft_doc::naming::face_names(body).len()).all(|i| solvecraft_doc::naming::face_name_exact(body, i)));
|
||||
change(&mut s, "h", "16 mm");
|
||||
for f in ["Corner", "Hole", "Top", "Peg"] {
|
||||
assert_eq!(error(&s, f), None, "{f}");
|
||||
assert_eq!(warning(&s, f), None, "{f}");
|
||||
}
|
||||
assert!((top(&s) - 20.0).abs() < 1e-6, "{}", top(&s));
|
||||
}
|
||||
@@ -106,11 +106,13 @@ fn enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen
|
||||
let errors = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
|
||||
let warnings = session.model.results.iter().filter_map(|r| r.warning.as_ref().map(|e| (&r.name, e))).collect::<Vec<_>>();
|
||||
assert!(errors.is_empty(), "errors {errors:?}");
|
||||
// The earlier snap-fit operation retains its documented legacy split-face
|
||||
// notice. Offset/projection failures or frozen borders are not accepted.
|
||||
// The earlier snap-fit operation retains its notice about its face reference: the
|
||||
// shell's inner wall it sits on, which the resized boss join (no exact history there)
|
||||
// leaves to be found by geometry. Offset/projection failures or frozen borders are not
|
||||
// accepted.
|
||||
assert!(
|
||||
warnings.iter().all(|(name, message)| name.as_str() == "SnapFit1"
|
||||
&& message.as_str() == "face `F11:side:bs#7` was split; using the piece nearest the picked point"),
|
||||
&& message.as_str() == "face `F4:inner:F2:side:l1` was found by geometry, not exact history; check it"),
|
||||
"warnings {warnings:?}"
|
||||
);
|
||||
assert!(session.model.results.iter().filter(|r| r.name == "Lid lip").all(|r| r.warning.is_none()));
|
||||
|
||||
+292
-4
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod mesh_convert;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
@@ -20,6 +22,28 @@ fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_as_tall_as_a_disc_uses_up_its_wall() {
|
||||
// #190: a disc 13 in radius and 2 high, its top edge rounded by 2.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 13, "height": 2}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[0, 13, 2], [0, -13, 2]], "radius": "2 mm"}));
|
||||
let (a, c) = ((1.0 - PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
|
||||
let expect = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c);
|
||||
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_as_wide_as_a_face_rounds_it_off() {
|
||||
// #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces).
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]], "radius": "3"}));
|
||||
let expect = 840.0 - (4.0 - PI) * 9.0 * 10.0;
|
||||
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": [[21, 0, 10]], "radius": 3.5})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_extrude_from_a_chamfered_top_face() {
|
||||
let mut s = Session::default();
|
||||
@@ -116,11 +140,75 @@ fn join_meeting_nothing_keeps_the_first_body() {
|
||||
assert!(rel(volume(&mut s), 1064.0) < 1e-6);
|
||||
}
|
||||
|
||||
mod boolean_diagnostics;
|
||||
mod component_frames;
|
||||
mod mirror_planes;
|
||||
mod primitive_points;
|
||||
mod robustness;
|
||||
|
||||
/// Plan area a round of radius `r` fills at the concave corner `x` where circles (c1, r1) and
|
||||
/// (c2, r2) cross: the triangle of its contacts and the corner, less the three circular segments.
|
||||
fn round_gap(c1: [f64; 2], r1: f64, c2: [f64; 2], r2: f64, x: [f64; 2], r: f64) -> f64 {
|
||||
let (a, b) = (r1 + r, r2 + r);
|
||||
let d = (c2[0] - c1[0]).hypot(c2[1] - c1[1]);
|
||||
let u = [(c2[0] - c1[0]) / d, (c2[1] - c1[1]) / d];
|
||||
let along = (a * a - b * b + d * d) / (2.0 * d);
|
||||
let side = if (x[0] - c1[0]) * -u[1] + (x[1] - c1[1]) * u[0] < 0.0 { -1.0 } else { 1.0 };
|
||||
let off = (a * a - along * along).sqrt() * side;
|
||||
let f = [c1[0] + u[0] * along - u[1] * off, c1[1] + u[1] * along + u[0] * off];
|
||||
let t1 = [c1[0] + (f[0] - c1[0]) * r1 / a, c1[1] + (f[1] - c1[1]) * r1 / a];
|
||||
let t2 = [c2[0] + (f[0] - c2[0]) * r2 / b, c2[1] + (f[1] - c2[1]) * r2 / b];
|
||||
let angle = |c: [f64; 2], p: [f64; 2], q: [f64; 2]| {
|
||||
let (v, w) = ([p[0] - c[0], p[1] - c[1]], [q[0] - c[0], q[1] - c[1]]);
|
||||
((v[0] * w[0] + v[1] * w[1]) / (v[0].hypot(v[1]) * w[0].hypot(w[1]))).clamp(-1.0, 1.0).acos()
|
||||
};
|
||||
let segment = |r: f64, t: f64| r * r * (t - t.sin()) / 2.0;
|
||||
let triangle = ((x[0] - t1[0]) * (t2[1] - t1[1]) - (t2[0] - t1[0]) * (x[1] - t1[1])).abs() / 2.0;
|
||||
triangle - segment(r1, angle(c1, t1, x)) - segment(r2, angle(c2, x, t2)) - segment(r, angle(f, t2, t1))
|
||||
}
|
||||
|
||||
/// A profile of two overlapping arcs, extruded: the vertical edges where the two walls meet are
|
||||
/// straight lines between cylinders, and their rounds end on the caps across curved edges.
|
||||
#[test]
|
||||
fn fillet_the_edges_between_two_overlapping_cylinders() {
|
||||
let (c1, r1) = ([0.0, 0.0], 17.140595088852663);
|
||||
let (c2, r2) = ([10.715766135516247, 13.378055020402751], 9.666944358807896);
|
||||
let (p, q) = ([1.7728134670876865, 17.048669520256194], [16.250324167975673, 5.452243981674495]);
|
||||
let profile = 1087.5018766864137;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": c1, "start": p, "end": q}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": c2, "start": q, "end": p}));
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
assert!(rel(si["profiles"][0]["area_mm2"].as_f64().unwrap(), profile) < 1e-9, "{si}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 10}));
|
||||
let counts = |s: &mut Session| {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
(m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64())
|
||||
};
|
||||
assert_eq!(counts(&mut s), (Some(4), Some(6), Some(4)));
|
||||
let (gp, gq) = (round_gap(c1, r1, c2, r2, p, 6.0), round_gap(c1, r1, c2, r2, q, 6.0));
|
||||
// One round: a blend face; the edge becomes two contact lines and two end arcs.
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[p[0], p[1], 5]], "radius": 6, "name": "Round"}));
|
||||
let (v, expect) = (volume(&mut s), (profile + gp) * 10.0);
|
||||
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
|
||||
assert_eq!(counts(&mut s), (Some(5), Some(9), Some(6)));
|
||||
// Both corners in one fillet.
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[p[0], p[1], 5], [q[0], q[1], 5]], "radius": 6}}),
|
||||
);
|
||||
let (v, expect) = (volume(&mut s), (profile + gp + gq) * 10.0);
|
||||
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
|
||||
assert_eq!(counts(&mut s), (Some(6), Some(12), Some(8)));
|
||||
// A larger round, whose contacts are found on the long arcs from their far ends too.
|
||||
run(&mut s, "timeline.redefine", json!({"feature": "Round", "command": "solid.fillet", "params": {"edges": [[q[0], q[1], 5]], "radius": 13}}));
|
||||
let (v, expect) = (volume(&mut s), (profile + round_gap(c1, r1, c2, r2, q, 13.0)) * 10.0);
|
||||
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
|
||||
}
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
#[test]
|
||||
fn box_fillet_cut_part() {
|
||||
@@ -446,11 +534,22 @@ fn registry_is_consistent() {
|
||||
let n = ids.len();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), n, "duplicate command ids");
|
||||
assert!(find_command("extrude").is_some(), "case-insensitive lookup");
|
||||
assert_eq!(find_command("Solid.Extrude").map(|c| c.id), Some("solid.extrude"), "case-insensitive lookup");
|
||||
let s = Session::default();
|
||||
assert!(!find_command("sketch.line").unwrap().info(&s).enabled);
|
||||
}
|
||||
|
||||
/// Only SolidCraft's own dotted ids name commands; other ids are unknown commands.
|
||||
#[test]
|
||||
fn only_own_ids_name_commands() {
|
||||
for old in ["Extrude", "SketchCreate", "FusionFilletEdgesCommand"] {
|
||||
assert!(find_command(old).is_none(), "{old}");
|
||||
let mut s = Session::default();
|
||||
let err = s.execute(old, &serde_json::json!({})).unwrap_err();
|
||||
assert!(matches!(err, crate::EngineError::UnknownCommand(_)), "{old}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_design_builds() {
|
||||
let mut s = Session::default();
|
||||
@@ -2051,6 +2150,67 @@ fn appearances_survive_later_features() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The original #2 RGB import acceptance, including later topology and placement edits.
|
||||
#[test]
|
||||
fn imported_rgb_appearances_survive_edits_reopen_and_step_export() {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-imported-rgb-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("rgb.step");
|
||||
let saved = dir.join("edited.solvecraft");
|
||||
let output = dir.join("edited.step");
|
||||
let mut source = Session::default();
|
||||
for (name, x, color) in [("Red", 0, "#ff0000"), ("Green", 40, "#00ff00"), ("Blue", 80, "#0000ff")] {
|
||||
run(&mut source, "solid.box", json!({"corner":[x,0,0],"length":30,"width":20,"height":20,"body_name":name}));
|
||||
run(&mut source, "appearance.assign", json!({"bodies":[name],"color":color}));
|
||||
}
|
||||
run(&mut source, "appearance.assign", json!({"faces":[[15,10,20]],"color":"#ffff00"}));
|
||||
run(&mut source, "file.export", json!({"path":input}));
|
||||
let check = |session: &Session| {
|
||||
let state = session.model.state();
|
||||
assert_eq!(state.bodies.len(), 3);
|
||||
for (name, color) in [("Red", [1., 0., 0.]), ("Green", [0., 1., 0.]), ("Blue", [0., 0., 1.])] {
|
||||
let body = state.body(name).unwrap();
|
||||
assert_eq!(session.doc.body_color(body), Some(color), "{name} retains its imported body colour");
|
||||
}
|
||||
let red = state.body("Red").unwrap();
|
||||
assert!(session.doc.face_colors(red).iter().any(|(_, look)| look.color == [255, 255, 0]), "Red retains its yellow top face");
|
||||
assert!(session.model.results.iter().all(|result| result.error.is_none()));
|
||||
};
|
||||
let mut edited = Session::default();
|
||||
run(&mut edited, "doc.open", json!({"path":input}));
|
||||
check(&edited);
|
||||
let initial_volume = volume(&mut edited);
|
||||
run(&mut edited, "solid.fillet", json!({"bodies":["Red"],"edges":[[0,10,20]],"radius":2}));
|
||||
check(&edited);
|
||||
run(&mut edited, "solid.move", json!({"bodies":["Red"],"translate":[0,0,5]}));
|
||||
check(&edited);
|
||||
run(&mut edited, "solid.box", json!({"corner":[85,5,15],"length":10,"width":10,"height":10,"body_name":"Cutter"}));
|
||||
run(&mut edited, "solid.combine", json!({"target":"Blue","tools":["Cutter"],"operation":"cut"}));
|
||||
check(&edited);
|
||||
run(&mut edited, "solid.box", json!({"corner":[65,0,0],"length":10,"width":20,"height":20,"body_name":"Joiner"}));
|
||||
run(&mut edited, "solid.combine", json!({"target":"Green","tools":["Joiner"],"operation":"join"}));
|
||||
check(&edited);
|
||||
let edited_volume = volume(&mut edited);
|
||||
// The join adds 5*20*20, the pocket removes 10*10*5, and the
|
||||
// radius-2 quarter-round replaces a 2*2 square along a 20 mm edge.
|
||||
let expected_volume = initial_volume + 2000. - 500. - (4. - PI) * 20.;
|
||||
assert!((edited_volume - expected_volume).abs() < 0.1, "{edited_volume} vs {expected_volume}");
|
||||
run(&mut edited, "edit.undo", json!({}));
|
||||
run(&mut edited, "edit.redo", json!({}));
|
||||
check(&edited);
|
||||
run(&mut edited, "file.save_as", json!({"path":saved}));
|
||||
let mut reopened = Session::default();
|
||||
run(&mut reopened, "doc.open", json!({"path":saved}));
|
||||
check(&reopened);
|
||||
assert!(rel(volume(&mut reopened), edited_volume) < 1e-8);
|
||||
run(&mut reopened, "file.export", json!({"path":output}));
|
||||
let mut roundtrip = Session::default();
|
||||
run(&mut roundtrip, "doc.open", json!({"path":output}));
|
||||
check(&roundtrip);
|
||||
assert!(rel(volume(&mut roundtrip), edited_volume) < 1e-3);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// Extrude To Object (a parallel face, with an offset; a slanted face) and From Object.
|
||||
#[test]
|
||||
fn extrude_to_and_from_objects() {
|
||||
@@ -2209,15 +2369,15 @@ fn boundary_fill_cells() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 20, "height": 20, "body_name": "B"}));
|
||||
let r = run(&mut s, "SurfaceSculpt", json!({"tools": ["A", "B"], "cells": [[15, 10, 10]], "remove_tools": true}));
|
||||
let r = run(&mut s, "solid.boundary_fill", json!({"tools": ["A", "B"], "cells": [[15, 10, 10]], "remove_tools": true}));
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
assert_eq!(m["body_count"], 1, "{r} {m}");
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 10.0 * 20.0 * 20.0) < 1e-9);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "SurfaceSculpt", json!({"tools": ["A", "B"], "cells": [[5, 10, 10], [25, 10, 10]], "remove_tools": true}));
|
||||
run(&mut s, "solid.boundary_fill", json!({"tools": ["A", "B"], "cells": [[5, 10, 10], [25, 10, 10]], "remove_tools": true}));
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}");
|
||||
assert!(s.execute("SurfaceSculpt", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
|
||||
assert!(s.execute("solid.boundary_fill", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err());
|
||||
}
|
||||
|
||||
/// Rectangles name their sides and corners, so they can be dimensioned without inspecting the
|
||||
@@ -2350,3 +2510,131 @@ fn special_files_are_refused_by_every_file_command() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A cut targeted at one body leaves a non-target body alone, even one the tool covers completely (#260).
|
||||
#[test]
|
||||
fn targeted_cut_keeps_a_covered_body_that_is_not_a_target() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "Frame"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new_body", "body_name": "Other"}));
|
||||
// Exactly Other's extent; it does not touch Frame.
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "cut", "targets": ["Frame"]}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Frame", "Other"]);
|
||||
assert!(rel(volume(&mut s), 2000.0) < 1e-6);
|
||||
}
|
||||
|
||||
/// A frame's opening cut over a separate mesh body that sits in it: the mesh survives (#260).
|
||||
#[test]
|
||||
fn targeted_cut_keeps_an_overlapping_body_that_is_not_a_target() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 50, "height": 5, "operation": "new_body", "body_name": "Frame"}));
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 1, "corner": [10, 10, 2], "operation": "new_body", "body_name": "Mesh"}));
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "corner": [10, 10, 0], "operation": "cut", "targets": ["Frame"]}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Frame", "Mesh"]);
|
||||
assert!(rel(volume(&mut s), 50.0 * 50.0 * 5.0 - 30.0 * 30.0 * 5.0 + 900.0) < 1e-6);
|
||||
}
|
||||
|
||||
/// A join with explicit `targets` merges into those bodies only; a touched non-target stays (#259).
|
||||
#[test]
|
||||
fn join_with_targets_leaves_other_touched_bodies_alone() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "operation": "new_body", "body_name": "B"}));
|
||||
// Straddles the face between A and B; only A is a target.
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [8, 3, 3], "operation": "join", "targets": ["A"]}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["A", "B"], "B is not a target and must stay a body");
|
||||
let b = run(&mut s, "inspect.measure", json!({"bodies": ["B"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(b, 1000.0) < 1e-6, "B is unchanged: {b}");
|
||||
// A gains the half of the tool outside it.
|
||||
let a = run(&mut s, "inspect.measure", json!({"bodies": ["A"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!(rel(a, 1032.0) < 1e-6, "A: {a}");
|
||||
}
|
||||
|
||||
/// Deleting a sketch takes its extrude with it; a later combine that names the extrude's body must
|
||||
/// not be left failing silently: it is deleted too, or the reply reports it (#267).
|
||||
#[test]
|
||||
fn deleting_a_sketch_leaves_no_silently_failing_feature() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": "10", "body_name": "P"}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [18, 5, 0], "operation": "new_body", "body_name": "Q"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "P", "tools": ["Q"], "name": "JoinPQ"}));
|
||||
let reply = run(&mut s, "timeline.delete", json!({"features": ["S"]}));
|
||||
if let Ok(join) = s.execute("timeline.inspect", &json!({"feature": "JoinPQ"})) {
|
||||
assert!(
|
||||
join["error"].is_null() || reply.to_string().contains("JoinPQ"),
|
||||
"JoinPQ now fails ({}) but the reply only says {reply}",
|
||||
join["error"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every body a multi-profile `new` extrude creates carries its `body_name`, not a generic BodyN (#266).
|
||||
#[test]
|
||||
fn multi_profile_extrude_names_every_body_after_body_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
for x in [0, 10, 20] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10", "operation": "new_body", "body_name": "Ribs"}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert!(names.iter().all(|n| n.starts_with("Ribs")), "{names:?}");
|
||||
assert!(rel(volume(&mut s), 4500.0) < 1e-6);
|
||||
}
|
||||
|
||||
/// Renaming one pattern copy leaves the other copies' names alone (#265).
|
||||
#[test]
|
||||
fn renaming_a_pattern_copy_keeps_the_other_copies_names() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
|
||||
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
|
||||
let third = run(&mut s, "inspect.measure", json!({"bodies": ["X (3)"]}));
|
||||
assert_eq!(third["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round), Some(40.0), "X (3) is still the copy at x = 40: {third}");
|
||||
run(&mut s, "body.rename", json!({"body": "X (3)", "name": "Third"}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["X", "Second", "Third"]);
|
||||
}
|
||||
|
||||
/// Parameters that work are listed in the command's params doc, which is all agents see (#271).
|
||||
#[test]
|
||||
fn params_docs_list_working_parameters() {
|
||||
let mut s = Session::default();
|
||||
let cmds = run(&mut s, "engine.commands", json!({}));
|
||||
let params =
|
||||
|id: &str| cmds.as_array().unwrap().iter().find(|c| c["id"] == id).and_then(|c| c["params"].as_str()).unwrap_or_default().to_string();
|
||||
// Both work today: a named base flange, and a loft cut limited to its targets.
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [50, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "A", "body_name": "Panel A"}));
|
||||
assert!(s.model.state().body("Panel A").is_some());
|
||||
assert!(params("sheet.flange").contains("body_name"), "sheet.flange: {}", params("sheet.flange"));
|
||||
assert!(params("solid.loft").contains("targets"), "solid.loft: {}", params("solid.loft"));
|
||||
}
|
||||
|
||||
/// Every body-creating feature documents the keys `add_feature` reads for all of them (`name`,
|
||||
/// `body_name`), and those that take `targets` say so (#271).
|
||||
#[test]
|
||||
fn feature_params_docs_list_shared_keys() {
|
||||
let mut s = Session::default();
|
||||
let cmds = run(&mut s, "engine.commands", json!({}));
|
||||
let params =
|
||||
|id: &str| cmds.as_array().unwrap().iter().find(|c| c["id"] == id).and_then(|c| c["params"].as_str()).unwrap_or_default().to_string();
|
||||
let with_targets = ["solid.extrude", "solid.revolve", "solid.sweep", "solid.loft", "solid.pipe", "solid.coil"];
|
||||
let without_targets = ["solid.box", "solid.cylinder", "solid.sphere", "solid.torus", "sheet.flange"];
|
||||
for id in with_targets.iter().chain(&without_targets) {
|
||||
let doc = params(id);
|
||||
assert!(doc.contains("name?") && doc.contains("body_name?"), "{id}: {doc}");
|
||||
}
|
||||
for id in with_targets {
|
||||
assert!(params(id).contains("targets?"), "{id}: {}", params(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use super::*;
|
||||
use solvecraft_kernel::BooleanStrategy;
|
||||
|
||||
#[test]
|
||||
fn boolean_retry_warning_and_details_survive_cache_undo_and_reopen() {
|
||||
let slope = 1f64.to_radians().tan();
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "sketch.create", json!({"plane":"XZ","name":"Floor section"}));
|
||||
run(&mut session, "sketch.line", json!({"points":[[0,0],[100,0],[100,-10.-slope*100.],[0,-10.]],"closed":true}));
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
run(&mut session, "solid.extrude", json!({"sketch":"Floor section","distance":80,"body_name":"Floor"}));
|
||||
run(&mut session, "solid.cylinder", json!({"base":[50,40,-5],"radius":10,"height":30,"operation":"cut"}));
|
||||
run(&mut session, "sketch.create", json!({"plane":"XY","name":"Ring section"}));
|
||||
run(&mut session, "sketch.circle.center", json!({"center":[50,40],"radius":20}));
|
||||
run(&mut session, "sketch.circle.center", json!({"center":[50,40],"radius":10}));
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut session,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Ring section","profiles":[{"point":[65,40]}],"start_offset":-5,"distance":7,"body_name":"Ring"}),
|
||||
);
|
||||
let id = run(&mut session, "solid.combine", json!({"target":"Floor","tools":["Ring"],"operation":"join"}))["feature"].as_u64().unwrap();
|
||||
let check = |session: &Session| {
|
||||
let result = session.model.result(id).unwrap();
|
||||
assert!(result.error.is_none());
|
||||
assert!(result.warning.as_ref().is_some_and(|warning| warning.contains("Boolean retry:")), "{result:?}");
|
||||
assert!(
|
||||
result.boolean_diagnostics.events.iter().any(|event| event.strategy == BooleanStrategy::CommonBore),
|
||||
"{:?}",
|
||||
result.boolean_diagnostics
|
||||
);
|
||||
result.boolean_diagnostics.clone()
|
||||
};
|
||||
let original = check(&session);
|
||||
let expected = (10. + slope * 50.) * (8000. - PI * 100.) + PI * 300. * 5.;
|
||||
assert!(rel(volume(&mut session), expected) < 2e-5);
|
||||
let inspected = run(&mut session, "timeline.inspect", json!({"feature":id}));
|
||||
assert_eq!(inspected["boolean_diagnostics"], serde_json::to_value(&original).unwrap());
|
||||
session.refresh();
|
||||
assert_eq!(check(&session), original);
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
run(&mut session, "edit.redo", json!({}));
|
||||
assert_eq!(check(&session), original);
|
||||
let reopened_doc = solvecraft_doc::Document::from_json(&session.doc.to_json()).unwrap();
|
||||
let reopened = Session::new(reopened_doc);
|
||||
assert_eq!(check(&reopened), original);
|
||||
// A later independent feature must not inherit the earlier retry's warning.
|
||||
run(&mut session, "solid.box", json!({"corner":[200,0,0],"length":5,"width":5,"height":5}));
|
||||
let last = session.model.results.last().unwrap();
|
||||
assert!(last.warning.is_none());
|
||||
assert!(last.boolean_diagnostics.events.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use super::*;
|
||||
use solvecraft_doc::{Document, FeatureKind, MeshData};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
fn imported_box() -> Session {
|
||||
let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
let mut doc = Document::default();
|
||||
doc.add_feature(
|
||||
FeatureKind::MeshImport {
|
||||
file: "public-safe-box.stl".into(),
|
||||
meshes: vec![MeshData {
|
||||
name: "Mesh".into(),
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: Some([0.2, 0.5, 0.8]),
|
||||
}],
|
||||
},
|
||||
Some("Imported mesh"),
|
||||
)
|
||||
.unwrap();
|
||||
Session::new(doc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_conversion_is_parametric_undoable_and_editable_after_cold_reopen() {
|
||||
let mut s = imported_box();
|
||||
run(&mut s, "parameters.add", json!({"name":"size","expression":"1","unit":""}));
|
||||
run(&mut s, "solid.scale", json!({"bodies":["Mesh"],"factor":"size","name":"Mesh scale"}));
|
||||
run(&mut s, "mesh.convert", json!({"body":"Mesh","tolerance":"0.01 mm","name":"Reconstructed prism"}));
|
||||
let check = |s: &Session| {
|
||||
let state = s.model.state();
|
||||
let body = &state.body("Mesh").unwrap().body;
|
||||
assert!(!body.is_mesh());
|
||||
assert_eq!(body.faces(0.01).unwrap().len(), 6);
|
||||
assert_eq!(body.color(), Some([0.2, 0.5, 0.8]));
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
|
||||
};
|
||||
check(&s);
|
||||
assert!((volume(&mut s) - 6000.).abs() < 1e-6);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.model.state().body("Mesh").unwrap().body.is_mesh());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
check(&s);
|
||||
run(&mut s, "parameters.change", json!({"name":"size","expression":"2"}));
|
||||
check(&s);
|
||||
assert!((volume(&mut s) - 48000.).abs() < 1e-5);
|
||||
let mut cold = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&cold);
|
||||
assert!((volume(&mut cold) - 48000.).abs() < 1e-5);
|
||||
run(&mut cold, "solid.fillet", json!({"body":"Mesh","edges":[[0,20,60]],"radius":2}));
|
||||
assert!(volume(&mut cold) < 48000.);
|
||||
assert!(cold.model.results.iter().all(|r| r.error.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_mesh_conversions_leave_the_design_unchanged() {
|
||||
let mut s = imported_box();
|
||||
let before = s.doc.to_json();
|
||||
for params in [
|
||||
json!({}),
|
||||
json!({"body":"Missing"}),
|
||||
json!({"body":"Mesh","tolerance":0}),
|
||||
json!({"body":"Mesh","tolerance":"unknown"}),
|
||||
json!({"body":"Mesh","tolerance":"2 mm"}),
|
||||
] {
|
||||
assert!(s.execute("mesh.convert", ¶ms).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert!(s.model.state().body("Mesh").unwrap().body.is_mesh());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_binary_stl_with_a_bore_reconstructs_analytic_faces() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Section"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,0],[40,0],[40,20],[20,20],[20,30],[0,30]],"closed":true}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[10,10],"radius":4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch":"Section","profiles":[{"point":[30,10]}],"distance":15,"body_name":"Bracket"}));
|
||||
let path = std::env::temp_dir().join(format!("solvecraft-mesh-convert-{}.stl", std::process::id()));
|
||||
run(&mut s, "file.save_mesh", json!({"path":path}));
|
||||
run(&mut s, "doc.open", json!({"path":path}));
|
||||
let name = s.model.state().bodies[0].name.clone();
|
||||
run(&mut s, "mesh.convert", json!({"body":name,"tolerance":"0.05 mm"}));
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(!body.is_mesh());
|
||||
assert!(body.validity().is_empty());
|
||||
assert_eq!(solvecraft_kernel::measure(body).unwrap().merged.face_types.get("cylinder"), Some(&1));
|
||||
assert_eq!(body.faces(0.01).unwrap().len(), 10);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Placement-cache changes must retain physical picking, pending positions and named joints.
|
||||
use crate::{Session, geom::Vec3};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn run(session: &mut Session, command: &str, params: Value) -> Value {
|
||||
session.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_placement_cache_preserves_pending_picks_materials_scratch_and_named_joint_frames() {
|
||||
let mut session = Session::default();
|
||||
let created = run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
run(&mut session, "material.assign", json!({"bodies":["Block"],"material":"Brass"}));
|
||||
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"axis":[0,0,1],"angle":"90 deg","capture":false}));
|
||||
let pending = session.pending_moves.clone();
|
||||
let path = vec![second];
|
||||
let placement = session.occurrence_placement(&path).unwrap();
|
||||
let name = placement.bodies[0].world.clone();
|
||||
let world = session.world_state();
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
let source_state = session.model.state();
|
||||
let source = source_state.body("Block").unwrap();
|
||||
let placed = world.body(&name).unwrap();
|
||||
assert_eq!(placed.source_name.as_deref(), Some("Block"));
|
||||
assert_eq!(placed.component, component);
|
||||
let bounds = placed.mesh().bounds();
|
||||
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
|
||||
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
|
||||
assert_eq!(session.doc.model_body_look(placed), session.doc.model_body_look(source));
|
||||
let source_names = crate::doc::naming::face_names(source);
|
||||
assert_eq!(source_names, crate::doc::naming::face_names(placed));
|
||||
let pick = Vec3::new(8.5, 21., 4.);
|
||||
let top = placed.body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap().index;
|
||||
run(&mut session, "select.set", json!({"items":[{"type":"face","body":name,"index":top,"point":[pick.x,pick.y,pick.z]}]}));
|
||||
assert!(matches!(&session.selection[0], crate::Sel::Face { body, index, .. } if body == &name && *index == top));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()), "selection changes must not discard prepared placements");
|
||||
let scratch = session.scratch();
|
||||
assert_eq!(scratch.pending_moves, pending);
|
||||
let scratch_world = scratch.world_state();
|
||||
assert!(Arc::ptr_eq(&world, &scratch_world));
|
||||
let scratch_body = scratch_world.body(&name).unwrap();
|
||||
assert!(scratch_body.mesh().bounds().min.dist(bounds.min) < 1e-9);
|
||||
assert_eq!(crate::doc::naming::face_names(scratch_body), source_names);
|
||||
run(&mut session, "component.capture_position", json!({}));
|
||||
run(&mut session, "joint.origin", json!({"name":"Top joint","occurrence":second,"face":[pick.x,pick.y,pick.z]}));
|
||||
let joint = session.doc.assembly.origins[0].origin.clone();
|
||||
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
|
||||
let before_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
|
||||
assert!(crate::doc::apply_point(&before_joint, Vec3::ZERO).dist(pick) < 1e-9);
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[5,0,0],"capture":true}));
|
||||
let moved = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&world, &moved), "a changed transform must invalidate placed meshes");
|
||||
assert!(moved.body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
|
||||
assert!(world.body(&name).unwrap().mesh().bounds().min.dist(bounds.min) < 1e-9, "prior snapshots must remain immutable");
|
||||
assert_eq!(crate::doc::naming::face_names(moved.body(&name).unwrap()), source_names);
|
||||
let local_frame = crate::doc::joints::resolve_origin(&session.doc, &session.model.state(), &joint).unwrap();
|
||||
let after_joint = crate::doc::mat_mul(&crate::doc::joints::occurrence_world(&session.doc, second), &local_frame);
|
||||
assert!(crate::doc::apply_point(&after_joint, Vec3::ZERO).dist(pick + Vec3::new(5., 0., 0.)) < 1e-9);
|
||||
let reopened = Session::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
assert!(reopened.world_state().body(&name).unwrap().mesh().bounds().min.dist(bounds.min + Vec3::new(5., 0., 0.)) < 1e-9);
|
||||
assert_eq!(reopened.model.results.iter().filter(|r| r.error.is_some()).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_visibility_section_and_view_revisions_reuse_world_geometry() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let world = session.world_state();
|
||||
let revision = session.revision;
|
||||
run(&mut session, "select.set", json!({"items":[{"type":"body","name":"Block"}]}));
|
||||
assert!(session.revision > revision);
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "select.clear", json!({}));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "browser.visibility", json!({"items":["Block"],"visible":false}));
|
||||
assert!(!session.body_visible("Block"));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
run(&mut session, "inspect.section", json!({"plane":{"origin":[0,0,2],"normal":[0,0,1]}}));
|
||||
assert!(session.section.is_some());
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
session.invalidate_view(session.revision + 10);
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state()));
|
||||
assert!(Arc::ptr_eq(&world, &session.scratch().world_state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_cache_requires_exact_design_model_pending_and_author_context() {
|
||||
let mut session = Session::default();
|
||||
let created = run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let sketch = run(&mut session, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap();
|
||||
run(&mut session, "sketch.finish", json!({}));
|
||||
let second = run(&mut session, "occurrence.copy", json!({"occurrence":first}))["occurrence"].as_u64().unwrap();
|
||||
let initial = session.world_state();
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":second,"translate":[10,20,0],"capture":false}));
|
||||
let pending = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&initial, &pending));
|
||||
let name = session.occurrence_placement(&[second]).unwrap().bodies[0].world.clone();
|
||||
assert!(pending.body(&name).unwrap().mesh().bounds().min.dist(Vec3::new(10., 20., 0.)) < 1e-9);
|
||||
run(&mut session, "component.activate", json!({"component":component,"occurrence":second,"path":[second]}));
|
||||
let authored = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&pending, &authored));
|
||||
assert!(authored.sketch(sketch).unwrap().plane.origin.dist(Vec3::new(10., 20., 0.)) < 1e-9);
|
||||
let mut scratch = session.scratch();
|
||||
assert!(Arc::ptr_eq(&authored, &scratch.world_state()));
|
||||
scratch.active_occurrence_path = Some(vec![first]);
|
||||
let scratch_world = scratch.world_state();
|
||||
assert!(!Arc::ptr_eq(&authored, &scratch_world));
|
||||
assert!(scratch_world.sketch(sketch).unwrap().plane.origin.dist(Vec3::ZERO) < 1e-9);
|
||||
assert!(Arc::ptr_eq(&authored, &session.world_state()), "scratch context must not replace the source cache");
|
||||
run(&mut session, "appearance.assign", json!({"bodies":["Block"],"color":"#ff0000"}));
|
||||
let painted = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&authored, &painted));
|
||||
assert_ne!(session.doc.model_body_look(painted.body("Block").unwrap()), None);
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
let undone = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&painted, &undone));
|
||||
run(&mut session, "edit.redo", json!({}));
|
||||
let redone = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&undone, &redone));
|
||||
assert!(session.doc.model_body_look(redone.body("Block").unwrap()).is_some());
|
||||
let before_edit = session.world_state();
|
||||
run(&mut session, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Other","operation":"new"}));
|
||||
let edited = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&before_edit, &edited));
|
||||
assert!(edited.body("Other").is_some());
|
||||
session.doc = Arc::new(crate::doc::Document::from_json(&session.doc.to_json()).unwrap());
|
||||
let replaced = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&edited, &replaced), "equal contents in a different document cannot share the cache key");
|
||||
session.model = crate::doc::Model::default();
|
||||
session.model.evaluate(&session.doc);
|
||||
assert!(!Arc::ptr_eq(&replaced, &session.world_state()), "a new evaluated model must invalidate even when the document Arc is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "component.create", json!({"name":"Part"}));
|
||||
run(&mut session, "solid.box", json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"}));
|
||||
let source = session.world_state();
|
||||
let source_json = session.doc.to_json();
|
||||
let mut scratch = session.scratch();
|
||||
run(&mut scratch, "solid.box", json!({"length":5,"width":6,"height":7,"body_name":"Draft","operation":"new"}));
|
||||
let warmed = scratch.world_state();
|
||||
assert!(!Arc::ptr_eq(&source, &warmed));
|
||||
assert_eq!(session.doc.to_json(), source_json);
|
||||
assert!(Arc::ptr_eq(&source, &session.world_state()));
|
||||
assert!(source.body("Draft").is_none());
|
||||
let feature = warmed.body("Draft").unwrap().feature;
|
||||
session.accept_computed(scratch);
|
||||
assert!(Arc::ptr_eq(&warmed, &session.world_state()), "adoption/view revision must retain the worker's prepared geometry");
|
||||
session.invalidate_view(session.revision + 20);
|
||||
assert!(Arc::ptr_eq(&warmed, &session.world_state()));
|
||||
run(&mut session, "timeline.rollback", json!({"position":1}));
|
||||
let rolled = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&warmed, &rolled));
|
||||
assert!(rolled.body("Block").is_some());
|
||||
assert!(rolled.body("Draft").is_none());
|
||||
run(&mut session, "timeline.rollback", json!({}));
|
||||
let restored = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&rolled, &restored));
|
||||
assert!(restored.body("Draft").is_some());
|
||||
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":true}));
|
||||
let suppressed = session.world_state();
|
||||
assert!(!Arc::ptr_eq(&restored, &suppressed));
|
||||
assert!(suppressed.body("Draft").is_none());
|
||||
run(&mut session, "timeline.suppress", json!({"feature":feature,"suppressed":false}));
|
||||
assert!(session.world_state().body("Draft").is_some());
|
||||
}
|
||||
@@ -244,8 +244,24 @@ impl Seg2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Parameter of the point of the segment closest to `p` (sampling, then Newton).
|
||||
/// Parameter of the closest point: exact projection for lines,
|
||||
/// sampling followed by Newton for the remaining curves.
|
||||
pub fn closest_param(&self, p: Vec2) -> f64 {
|
||||
let finite = |p: Vec2| p.x.is_finite() && p.y.is_finite();
|
||||
match *self {
|
||||
Seg2::Line { a, b } if finite(a) && finite(b) && finite(p) => {
|
||||
let d = b - a;
|
||||
if a == b {
|
||||
return 0.0;
|
||||
}
|
||||
let denominator = d.len2();
|
||||
let t = (p - a).dot(d) / denominator;
|
||||
if denominator > 0.0 && denominator.is_finite() && t.is_finite() {
|
||||
return t.clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let n = 64;
|
||||
let mut t =
|
||||
(0..=n).map(|i| i as f64 / n as f64).min_by(|x, y| self.point_at(*x).dist(p).total_cmp(&self.point_at(*y).dist(p))).unwrap_or(0.0);
|
||||
@@ -381,9 +397,13 @@ impl Loop2 {
|
||||
pub fn reversed(&self) -> Loop2 {
|
||||
Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() }
|
||||
}
|
||||
/// Whether the loop runs counter-clockwise ([`Loop2::ccw`] keeps it as it is).
|
||||
pub fn is_ccw(&self) -> bool {
|
||||
self.signed_area() >= 0.0
|
||||
}
|
||||
/// Counter-clockwise copy.
|
||||
pub fn ccw(&self) -> Loop2 {
|
||||
if self.signed_area() < 0.0 { self.reversed() } else { self.clone() }
|
||||
if self.is_ccw() { self.clone() } else { self.reversed() }
|
||||
}
|
||||
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
|
||||
let mut out = Vec::new();
|
||||
@@ -540,6 +560,76 @@ impl Region2 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn closest_line_projection_and_degeneracy() {
|
||||
let line = Seg2::Line { a: Vec2::new(-3.0, 4.0), b: Vec2::new(7.0, 4.0) };
|
||||
for (p, t) in [(Vec2::new(-9.0, 20.0), 0.0), (Vec2::new(2.0, -5.0), 0.5), (Vec2::new(15.0, 4.0), 1.0)] {
|
||||
assert_eq!(line.closest_param(p), t);
|
||||
assert_eq!(line.reversed().closest_param(p), 1.0 - t);
|
||||
}
|
||||
let a = Vec2::new(1e100, -1e100);
|
||||
let b = a + Vec2::new(1e90, 2e90);
|
||||
let large = Seg2::Line { a, b };
|
||||
assert!((large.closest_param(a.lerp(b, 0.5)) - 0.5).abs() < 1e-6);
|
||||
assert_eq!(Seg2::Line { a, b: a }.closest_param(Vec2::ZERO), 0.0);
|
||||
let tiny = Seg2::Line { a: Vec2::ZERO, b: Vec2::new(1e-12, 0.0) };
|
||||
assert_eq!(tiny.closest_param(Vec2::new(0.5e-12, 1.0)), 0.5);
|
||||
for line in [line, line.reversed(), tiny] {
|
||||
for k in 0..53 {
|
||||
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
|
||||
let closest = line.point_at(line.closest_param(p)).dist(p);
|
||||
let dense = (0..=8192).map(|i| line.point_at(i as f64 / 8192.0).dist(p)).fold(f64::INFINITY, f64::min);
|
||||
assert!(closest <= dense + 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sampled_closest(s: &Seg2, p: Vec2) -> f64 {
|
||||
let mut t = (0..=64).map(|i| i as f64 / 64.0).min_by(|x, y| s.point_at(*x).dist(p).total_cmp(&s.point_at(*y).dist(p))).unwrap_or(0.0);
|
||||
for _ in 0..30 {
|
||||
let (q, d) = (s.point_at(t), s.derivative(t));
|
||||
let (d2, h) = ((s.derivative((t + 1e-6).min(1.0)) - s.derivative((t - 1e-6).max(0.0))) / 2e-6, q - p);
|
||||
let df = d.dot(d) + h.dot(d2);
|
||||
if df.abs() < 1e-300 {
|
||||
break;
|
||||
}
|
||||
let nt = (t - h.dot(d) / df).clamp(0.0, 1.0);
|
||||
if (nt - t).abs() < 1e-14 {
|
||||
t = nt;
|
||||
break;
|
||||
}
|
||||
t = nt;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closest_projection_preserves_clearance_tolerance_and_generic_fallback() {
|
||||
use std::f64::consts::TAU;
|
||||
let curves = [
|
||||
Seg2::Line { a: Vec2::new(-4.0, 7.0), b: Vec2::new(3.0, -2.0) },
|
||||
Seg2::Arc { center: Vec2::new(3.0, -2.0), radius: 6.0, start: 5.9, sweep: 1.3 },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.2, sweep: -TAU },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: TAU },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 0.0 },
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: 1e-12 },
|
||||
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(2.0, 7.0), p2: Vec2::new(6.0, -3.0), p3: Vec2::new(8.0, 4.0) },
|
||||
];
|
||||
for (i, s) in curves.iter().enumerate() {
|
||||
for k in 0..101 {
|
||||
let p = Vec2::new((k * 17 % 37) as f64 - 18.0, (k * 31 % 29) as f64 - 14.0);
|
||||
let old = sampled_closest(s, p);
|
||||
let new = s.closest_param(p);
|
||||
if i > 0 {
|
||||
assert_eq!(new.to_bits(), old.to_bits());
|
||||
}
|
||||
assert!(s.point_at(new).dist(p) <= s.point_at(old).dist(p) + 1e-10);
|
||||
let on_curve = s.point_at(k as f64 / 100.0);
|
||||
assert!(s.point_at(s.closest_param(on_curve)).dist(on_curve) < 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangulated_area() {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
|
||||
|
||||
+166
-49
@@ -13,6 +13,8 @@ use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::history::{FaceOrigin, History};
|
||||
use crate::record::{self, Rebuild};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -43,10 +45,15 @@ pub(crate) fn plane_normal(f: &mt::Face) -> Option<Vec3> {
|
||||
/// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra
|
||||
/// edges where consecutive edges no longer connect.
|
||||
pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
rebuild_face_dropping(f, subst, &[], connectors)
|
||||
}
|
||||
|
||||
/// [`rebuild_face`], also leaving out the `dropped` edges (consumed by a blend).
|
||||
fn rebuild_face_dropping(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, dropped: &[mt::EdgeID], connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
for e in w.edge_iter().filter(|e| !dropped.contains(&e.id())) {
|
||||
let ne = match subst.get(&e.id()) {
|
||||
Some(n) => {
|
||||
// `n` was built in the orientation of the stored edge; match this use.
|
||||
@@ -84,7 +91,8 @@ pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>,
|
||||
crate::heal::absolute_face(f, wires).ok_or_else(|| KernelError::Failed("blend: a face could not be rebuilt".into()))
|
||||
}
|
||||
|
||||
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<Solid> {
|
||||
/// Blend one edge of `solid`, with what was built from it (see [`Rebuild`]).
|
||||
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<(Solid, Rebuild)> {
|
||||
if !matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("only straight edges can be blended"));
|
||||
}
|
||||
@@ -198,10 +206,11 @@ fn blend_geometry(
|
||||
size: f64,
|
||||
r: f64,
|
||||
shape: Shape,
|
||||
) -> Result<Solid> {
|
||||
) -> Result<(Solid, Rebuild)> {
|
||||
let s = setback(r, phi, shape);
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
// End faces and the side edges at each end.
|
||||
// End faces and the side edges at each end. A side edge that the fillet's setback uses up
|
||||
// exactly is consumed: the rail ends at its far vertex (`ca`, `cb`).
|
||||
struct End {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
@@ -209,6 +218,8 @@ fn blend_geometry(
|
||||
e2: mt::Edge,
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
ca: Option<mt::Vertex>,
|
||||
cb: Option<mt::Vertex>,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
@@ -238,19 +249,27 @@ fn blend_geometry(
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
|
||||
let mut consumed: [Option<mt::Vertex>; 2] = [None, None];
|
||||
for (k, (e, q, t, fi)) in [(&e1, a, t1, i1), (&e2, b, t2, i2)].into_iter().enumerate() {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let far = if e.front() == v { e.back().clone() } else { e.front().clone() };
|
||||
let other = vtx(&far);
|
||||
let delta = other - p;
|
||||
if shape == Shape::Round {
|
||||
// Fillets retain the square-end check: the original corner edge
|
||||
// must run in the setback direction and have enough length left.
|
||||
// must run in the setback direction and have enough length left,
|
||||
// or exactly the setback (the edge is consumed).
|
||||
let along = delta.dot(t);
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along < s - tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
if along <= s + tol
|
||||
&& let Some(slot) = consumed.get_mut(k)
|
||||
{
|
||||
*slot = Some(far);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let len = delta.len();
|
||||
@@ -265,7 +284,8 @@ fn blend_geometry(
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b });
|
||||
let [ca, cb] = consumed;
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b, ca, cb });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
@@ -274,10 +294,32 @@ fn blend_geometry(
|
||||
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(end0.a));
|
||||
let b0 = builder::vertex(p3(end0.b));
|
||||
let a1 = builder::vertex(p3(end1.a));
|
||||
let b1 = builder::vertex(p3(end1.b));
|
||||
// A side face whose corner edges are consumed at both ends is used up: its remaining edge,
|
||||
// parallel to the old edge at the setback, becomes the rail and the face is removed.
|
||||
let used_up = |fi: usize, c0: &Option<mt::Vertex>, c1: &Option<mt::Vertex>| -> Result<Option<mt::Edge>> {
|
||||
let (Some(x0), Some(x1)) = (c0, c1) else {
|
||||
return match (c0, c1) {
|
||||
(None, None) => Ok(None),
|
||||
_ => Err(unsupported("the blend uses up a neighbouring face at one end only")),
|
||||
};
|
||||
};
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
|
||||
return Err(unsupported("the blend uses up a neighbouring face with more edges"));
|
||||
}
|
||||
let rail = f
|
||||
.edge_iter()
|
||||
.find(|e| matches!(e.curve(), mt::Curve::Line(_)) && ((e.front() == x0 && e.back() == x1) || (e.front() == x1 && e.back() == x0)))
|
||||
.ok_or_else(|| unsupported("the blend uses up a neighbouring face with more edges"))?;
|
||||
Ok(Some(if rail.front() == x0 { rail.clone() } else { rail.inverse() }))
|
||||
};
|
||||
let rail_a = used_up(i1, &end0.ca, &end1.ca)?;
|
||||
let rail_b = used_up(i2, &end0.cb, &end1.cb)?;
|
||||
let vertex = |c: &Option<mt::Vertex>, p: Vec3| c.clone().unwrap_or_else(|| builder::vertex(p3(p)));
|
||||
let a0 = vertex(&end0.ca, end0.a);
|
||||
let b0 = vertex(&end0.cb, end0.b);
|
||||
let a1 = vertex(&end1.ca, end1.a);
|
||||
let b1 = vertex(&end1.cb, end1.b);
|
||||
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
|
||||
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
|
||||
// middle is the point nearest the old edge.
|
||||
@@ -294,20 +336,32 @@ fn blend_geometry(
|
||||
};
|
||||
let c0 = corner(&a0, &b0, p0);
|
||||
let c1 = corner(&a1, &b1, p1);
|
||||
let la = builder::line(&a0, &a1);
|
||||
let lb = builder::line(&b0, &b1);
|
||||
let la = rail_a.clone().unwrap_or_else(|| builder::line(&a0, &a1));
|
||||
let lb = rail_b.clone().unwrap_or_else(|| builder::line(&b0, &b1));
|
||||
|
||||
// Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B.
|
||||
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped); the
|
||||
// blended edge moves to A or B.
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut dropped: Vec<mt::EdgeID> = Vec::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
|
||||
if used.is_some() {
|
||||
dropped.push(e.id());
|
||||
continue;
|
||||
}
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
}
|
||||
}
|
||||
// The side edges at the ends are pieces of themselves; the rails and corner curves are new.
|
||||
let mut rec = Rebuild::default();
|
||||
rec.pieces(&subst);
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
if (i == i1 && rail_a.is_some()) || (i == i2 && rail_b.is_some()) {
|
||||
continue;
|
||||
}
|
||||
let nf = if i == i1 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), la.clone());
|
||||
@@ -317,12 +371,13 @@ fn blend_geometry(
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == end0.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
|
||||
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c0))?
|
||||
} else if i == end1.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
|
||||
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c1))?
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
rec.face(&nf, f);
|
||||
new_faces.push(nf);
|
||||
}
|
||||
// The blend face uses each new edge opposite to its neighbour.
|
||||
@@ -351,13 +406,15 @@ fn blend_geometry(
|
||||
mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?
|
||||
}
|
||||
};
|
||||
rec.blend(&blend_face, [edge.id()]);
|
||||
new_faces.push(blend_face);
|
||||
let mut shells = solid.boundaries().clone();
|
||||
let new_shell: mt::Shell = new_faces.into();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_shell;
|
||||
}
|
||||
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))
|
||||
let solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))?;
|
||||
Ok((solid, rec))
|
||||
}
|
||||
|
||||
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
|
||||
@@ -370,8 +427,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
if edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("too many edges".into()));
|
||||
}
|
||||
// Each step's result carries its history over `[body]`.
|
||||
let counts = record::counts(&[body]);
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
let mut cur = crate::boolean_record::healed(body)?;
|
||||
// Every edge of a convex planar body: rounded as a whole (sphere corners).
|
||||
if shape == Shape::Round && edges.len() >= 6 {
|
||||
let solid = cur.deep_copy();
|
||||
@@ -388,7 +447,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
if hit.len() == all.len()
|
||||
&& let Ok(b) = crate::polyhedron::round_all_edges(&cur, r)
|
||||
{
|
||||
return Ok(b);
|
||||
return Ok(record::after(b, &cur, &counts));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -401,8 +460,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
{
|
||||
let _ = groups;
|
||||
let loop_pts: Vec<Vec3> = edges.iter().filter(|p| !rest.iter().any(|q| q.dist(**p) < 1e-12)).copied().collect();
|
||||
cur = blend(&cur, &rest, r, shape, what)?;
|
||||
return blend(&cur, &loop_pts, r, shape, what);
|
||||
cur = record::after(blend(&cur, &rest, r, shape, what)?, &cur, &counts);
|
||||
return Ok(record::after(blend(&cur, &loop_pts, r, shape, what)?, &cur, &counts));
|
||||
}
|
||||
if let Some(groups) = loop_groups(&cur, edges)? {
|
||||
for g in groups {
|
||||
@@ -410,7 +469,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
let solid = cur.deep_copy();
|
||||
let ids = edge_ids(&cur, &solid, &g)?;
|
||||
match crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) {
|
||||
Some(res) => cur = Body::new(guard(what, || res)?)?,
|
||||
Some(res) => {
|
||||
let (made, rec) = guard(what, || res)?;
|
||||
cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts);
|
||||
}
|
||||
None => return Err(unsupported("the edges are not a whole loop of a planar face")),
|
||||
}
|
||||
}
|
||||
@@ -429,10 +491,10 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
let solid = cur.deep_copy();
|
||||
if todo.len() >= 2
|
||||
&& let Some((ids, covered)) = run_on_loop(&cur, &solid, &todo)?
|
||||
&& let Some(Ok(res)) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
|
||||
&& let Ok(b) = guard(what, || Ok(res)).and_then(Body::new)
|
||||
&& let Some(Ok((res, rec))) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round)
|
||||
&& let Ok(b) = guard(what, || Ok(res)).and_then(|res| record::rebuilt(res, &[&solid], &rec))
|
||||
{
|
||||
cur = b;
|
||||
cur = record::after(b, &cur, &counts);
|
||||
todo.retain(|p| !covered.iter().any(|q| q.dist(*p) < 1e-12));
|
||||
continue;
|
||||
}
|
||||
@@ -469,7 +531,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
None => Err(e),
|
||||
},
|
||||
};
|
||||
match attempt.and_then(|(o, js)| Body::new(o).map(|b| (b, js))) {
|
||||
match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) {
|
||||
Ok(x) => {
|
||||
done = Some(x);
|
||||
break;
|
||||
@@ -483,7 +545,7 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul
|
||||
}
|
||||
match done {
|
||||
Some((b, js)) => {
|
||||
cur = b;
|
||||
cur = record::after(b, &cur, &counts);
|
||||
let mut k = 0;
|
||||
todo.retain(|_| {
|
||||
let keep = !js.contains(&k);
|
||||
@@ -636,7 +698,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
|
||||
pub(crate) fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
@@ -762,15 +824,21 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("select 1…1000 edges".into()));
|
||||
}
|
||||
// Each step's result carries its history over `[body]`.
|
||||
let counts = record::counts(&[body]);
|
||||
// All cutters at once (their union mitres the corners), else one edge at a time.
|
||||
let tools: Result<Vec<Body>> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect();
|
||||
let tools: Result<Vec<Cutter>> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect();
|
||||
if let Ok(tools) = tools {
|
||||
let mut acc: Option<Body> = None;
|
||||
// The union so far, with its history over the cutters it joined.
|
||||
let mut acc: Option<(Body, Option<History>)> = None;
|
||||
let mut ok = true;
|
||||
for t in tools {
|
||||
for t in &tools {
|
||||
let next = match &acc {
|
||||
None => Some(t),
|
||||
Some(a) => crate::ops::boolean(a, &t, crate::BoolOp::Union).ok().flatten(),
|
||||
None => Some((t.body.clone(), Some(History::identity(&t.body)))),
|
||||
Some((a, h)) => crate::ops::boolean(a, &t.body, crate::BoolOp::Union).ok().flatten().map(|u| {
|
||||
let h = crate::boolean_record::through(&u, 0, h.as_ref());
|
||||
(u, h)
|
||||
}),
|
||||
};
|
||||
if next.is_none() {
|
||||
ok = false;
|
||||
@@ -779,10 +847,11 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
acc = next;
|
||||
}
|
||||
if ok
|
||||
&& let Some(tool) = acc
|
||||
&& let Some((tool, h)) = acc
|
||||
&& let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut)
|
||||
{
|
||||
return Ok(r);
|
||||
let h = cut_history(crate::boolean_record::through(&r, 1, h.as_ref()), body, &tools);
|
||||
return Ok(r.with_checked_history(h, &counts));
|
||||
}
|
||||
}
|
||||
// At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild
|
||||
@@ -794,28 +863,49 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
return Ok(result);
|
||||
}
|
||||
// Coplanar neighbours as one face (as after a join), so corners look as they should.
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
let mut cur = crate::boolean_record::healed(body)?;
|
||||
if let Some(groups) = loop_groups(&cur, edges)? {
|
||||
for g in groups {
|
||||
let size = cur.size();
|
||||
let solid = cur.deep_copy();
|
||||
let ids = edge_ids(&cur, &solid, &g)?;
|
||||
match crate::loopblend::loop_blend(&solid, &ids, size, distance, false) {
|
||||
Some(res) => cur = Body::new(guard("chamfer", || res)?)?,
|
||||
Some(res) => {
|
||||
let (made, rec) = guard("chamfer", || res)?;
|
||||
cur = record::after(record::rebuilt(made, &[&solid], &rec)?, &cur, &counts);
|
||||
}
|
||||
None => return Err(unsupported("the edges are not a whole loop of a planar face")),
|
||||
}
|
||||
}
|
||||
return Ok(cur);
|
||||
}
|
||||
for p in edges {
|
||||
cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) {
|
||||
let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) {
|
||||
Ok(Some(b)) => b,
|
||||
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?,
|
||||
};
|
||||
cur = record::after(step, &cur, &counts);
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// `cur` cut by one cutter, with its history over `[cur]` (`None`: nothing left).
|
||||
fn cut_one(cur: &Body, t: &Cutter) -> Result<Option<Body>> {
|
||||
let counts = record::counts(&[cur]);
|
||||
Ok(crate::ops::boolean(cur, &t.body, crate::BoolOp::Cut)?.map(|r| {
|
||||
let h = cut_history(r.history().cloned(), cur, std::slice::from_ref(t));
|
||||
r.with_checked_history(h, &counts)
|
||||
}))
|
||||
}
|
||||
|
||||
/// A cut of `cur` by cutters (their union), from its history `h` over `[cur, cutter 0, …]`, as
|
||||
/// over `[cur]`: each cutter's chamfer face is made in place of its edge, and nothing else of a
|
||||
/// cutter may be left (`None` where something is).
|
||||
fn cut_history(h: Option<History>, cur: &Body, tools: &[Cutter]) -> Option<History> {
|
||||
let parts: Vec<History> = std::iter::once(History::identity(cur)).chain(tools.iter().map(Cutter::history)).collect();
|
||||
crate::boolean_record::composed(h?.over(&parts.iter().collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Geometry of a straight convex edge between two planar faces.
|
||||
struct EdgeFrame {
|
||||
p0: Vec3,
|
||||
@@ -827,7 +917,8 @@ struct EdgeFrame {
|
||||
t2: Vec3,
|
||||
}
|
||||
|
||||
fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
|
||||
/// The edge nearest `p` (its index in `cur`'s edge order) and its frame.
|
||||
fn edge_frame(cur: &Body, p: Vec3) -> Result<(usize, EdgeFrame)> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let Some((idx, dist)) = cur.nearest_edge(p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
|
||||
@@ -855,7 +946,7 @@ fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
|
||||
if n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 || t1.dot(t2) < -1.0 + 1e-6 {
|
||||
return Err(unsupported("only convex edges"));
|
||||
}
|
||||
Ok(EdgeFrame { p0, p1, d, n1, n2, t1, t2 })
|
||||
Ok((idx, EdgeFrame { p0, p1, d, n1, n2, t1, t2 }))
|
||||
}
|
||||
|
||||
/// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that
|
||||
@@ -885,20 +976,41 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("select 1…1000 edges".into()));
|
||||
}
|
||||
let mut cur = body.clone();
|
||||
let counts = record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::operand(body, 0, &counts);
|
||||
for (k, p) in edges.iter().enumerate() {
|
||||
let t = chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k)?;
|
||||
cur = crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
|
||||
let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
|
||||
cur = record::after(step, &cur, &counts);
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> {
|
||||
/// The prism that cuts a chamfer: the body, which of its faces is the chamfer plane, and the
|
||||
/// edge (of the body it chamfers, in its edge order) that face is made in place of.
|
||||
pub(crate) struct Cutter {
|
||||
body: Body,
|
||||
face: Option<usize>,
|
||||
edge: usize,
|
||||
}
|
||||
|
||||
impl Cutter {
|
||||
/// The cutter as made from the body it chamfers: its chamfer face in place of the edge, its
|
||||
/// other faces (all outside that body) from nothing of it.
|
||||
fn history(&self) -> History {
|
||||
let faces = (0..self.body.face_count())
|
||||
.map(|i| record::generated(if Some(i) == self.face { vec![FaceOrigin::Edge { operand: 0, edge: self.edge }] } else { Vec::new() }))
|
||||
.collect();
|
||||
record::from_faces(&self.body.solid, 1, faces)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Cutter> {
|
||||
chamfer_tool_sides(cur, p, s, None, k)
|
||||
}
|
||||
|
||||
fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result<Body> {
|
||||
let mut f = edge_frame(cur, p)?;
|
||||
fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, bool)>, k: usize) -> Result<Cutter> {
|
||||
let (edge, mut f) = edge_frame(cur, p)?;
|
||||
// Setbacks along each face: equal, or by the second side's rule.
|
||||
let (s1, s2) = match sides {
|
||||
None => (s, s),
|
||||
@@ -949,11 +1061,16 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b
|
||||
}
|
||||
let loc: Vec<solvecraft_geom::Vec2> = [a_ext, b_ext, e_far].iter().map(|q| plane.to_local(*q)).collect();
|
||||
let mut lp = solvecraft_geom::Loop2::polygon(&loc);
|
||||
// The chamfer plane is swept from the segment a → b: first, or last once turned round.
|
||||
let mut chamfer_segment = 0;
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
chamfer_segment = lp.segs.len().saturating_sub(1);
|
||||
}
|
||||
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
|
||||
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
|
||||
let body = crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))?;
|
||||
let face = body.history().and_then(|h| h.faces.iter().position(|f| f.origins == [FaceOrigin::Profile { segment: chamfer_segment }]));
|
||||
Ok(Cutter { body, face, edge })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -33,13 +33,15 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
if edges.iter().any(|(p, r)| !(p.is_finite() && r.is_finite() && *r > 1e-6 && *r < 1e6)) {
|
||||
return Err(KernelError::Invalid("fillet size must be positive".into()));
|
||||
}
|
||||
let mut cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
// Each step's result carries its history over `[body]`.
|
||||
let counts = crate::record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::healed(body)?;
|
||||
let mut sel = edges.to_vec();
|
||||
let mut first: Option<KernelError> = None;
|
||||
// Earlier rounds at the edges' ends come off and are blended again with the new edges.
|
||||
for _ in 0..4 {
|
||||
let err = match build(&cur, &sel, true) {
|
||||
Ok(b) => return Ok(b),
|
||||
Ok(b) => return Ok(crate::record::after(b, &cur, &counts)),
|
||||
Err(e) => e,
|
||||
};
|
||||
let rounds = rounds_at(&cur, &sel);
|
||||
@@ -48,7 +50,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
}
|
||||
first.get_or_insert(err);
|
||||
let idx: Vec<usize> = rounds.iter().map(|r| r.0).collect();
|
||||
cur = crate::delete_faces(&cur, &idx)?;
|
||||
cur = crate::record::after(crate::delete_faces(&cur, &idx)?, &cur, &counts);
|
||||
sel.extend(rounds.iter().map(|r| (r.1, r.2)));
|
||||
}
|
||||
Err(first.unwrap_or_else(|| unsupported("these fillets")))
|
||||
@@ -57,9 +59,9 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
||||
/// Local equal-distance chamfers, including pocket rims where adjacent walls have
|
||||
/// different slopes. The planar strips trim each other at each selected corner.
|
||||
pub(crate) fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
let cur = Body::new(crate::heal::heal_keep(body.deep_copy(), body.size(), body.split_keep()))?;
|
||||
let cur = crate::boolean_record::healed(body)?;
|
||||
let selected: Vec<_> = edges.iter().map(|p| (*p, distance)).collect();
|
||||
build(&cur, &selected, false)
|
||||
Ok(crate::record::after(build(&cur, &selected, false)?, &cur, &crate::record::counts(&[body])))
|
||||
}
|
||||
|
||||
/// A face of the shell being rebuilt, or a new face.
|
||||
@@ -311,7 +313,8 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
// New end points: (edge, vertex, face for a filleted edge's contact line).
|
||||
let mut endpt: HashMap<(mt::EdgeID, mt::VertexID, Option<usize>), mt::Vertex> = HashMap::new();
|
||||
let mut pool: HashMap<mt::VertexID, Vec<(mt::Edge, Vec<Owner>)>> = HashMap::new();
|
||||
let mut patches: Vec<mt::Face> = Vec::new();
|
||||
// Corner patches, with the edges meeting at their corner.
|
||||
let mut patches: Vec<(mt::Face, [mt::EdgeID; 3])> = Vec::new();
|
||||
let face_with = |a: mt::EdgeID, b: mt::EdgeID| -> Result<usize> {
|
||||
let fa = on_edge.get(&a).cloned().unwrap_or_default();
|
||||
let fb = on_edge.get(&b).cloned().unwrap_or_default();
|
||||
@@ -440,7 +443,8 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
let c3 = edge(&v23, &v31, mt::Curve::NurbsCurve(t3))?;
|
||||
let surf = cp.surf;
|
||||
let wire: mt::Wire = vec![c2.clone(), c3.clone(), c1.inverse()].into();
|
||||
patches.push(mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?);
|
||||
patches
|
||||
.push((mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?, [*e1, *e2, *e3]));
|
||||
list.push((c1, vec![Owner::Cyl(*e1), Owner::Patch]));
|
||||
list.push((c2, vec![Owner::Cyl(*e2), Owner::Patch]));
|
||||
list.push((c3, vec![Owner::Cyl(*e3), Owner::Patch]));
|
||||
@@ -468,6 +472,7 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
Ok(x)
|
||||
};
|
||||
let moved = |e: &mt::Edge| touched.contains_key(&e.absolute_front().id()) || touched.contains_key(&e.absolute_back().id());
|
||||
let mut rec = crate::record::Rebuild::default();
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + sels.len() + patches.len());
|
||||
for (fi, f) in faces.iter().enumerate() {
|
||||
if !f.vertex_iter().any(|v| touched.contains_key(&v.id())) {
|
||||
@@ -482,7 +487,10 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
let ne = if sels.contains_key(&e.id()) {
|
||||
line_of(e, Some(fi))?
|
||||
} else if moved(e) {
|
||||
line_of(e, None)?
|
||||
// The edge itself, shortened at the corner.
|
||||
let piece = line_of(e, None)?;
|
||||
rec.piece(&piece, e);
|
||||
piece
|
||||
} else {
|
||||
news.push(e.clone());
|
||||
continue;
|
||||
@@ -501,7 +509,9 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
}
|
||||
wires.push(mt::Wire::from(out));
|
||||
}
|
||||
new_faces.push(mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| KernelError::Failed(format!("fillet: face: {e}")))?);
|
||||
let rebuilt = mt::Face::try_new(wires, f.oriented_surface()).map_err(|e| KernelError::Failed(format!("fillet: face: {e}")))?;
|
||||
rec.face(&rebuilt, f);
|
||||
new_faces.push(rebuilt);
|
||||
}
|
||||
// The blends: back along the first face's contact line, round the corner, along the
|
||||
// second's, round the other corner.
|
||||
@@ -518,10 +528,13 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
w.extend(connect(first.back(), second.front(), p1, Owner::Cyl(*id))?);
|
||||
w.push(second.clone());
|
||||
w.extend(connect(second.back(), first.front(), p2, Owner::Cyl(*id))?);
|
||||
new_faces.push(mt::Face::try_new(vec![w.into()], s.surf.clone()).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?);
|
||||
let blend = mt::Face::try_new(vec![w.into()], s.surf.clone()).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
|
||||
rec.blend(&blend, [*id]);
|
||||
new_faces.push(blend);
|
||||
}
|
||||
// Corner patches run each section opposite to its blend.
|
||||
for mut p in patches {
|
||||
// Corner patches run each section opposite to its blend; each is made in place of the
|
||||
// edges meeting at its corner.
|
||||
for (mut p, corner) in patches {
|
||||
let uses = |f: &mt::Face, id: mt::EdgeID| {
|
||||
f.boundaries().iter().flat_map(|w| w.edge_iter().cloned().collect::<Vec<_>>()).find(|x| x.id() == id).map(|x| x.front().id())
|
||||
};
|
||||
@@ -535,14 +548,15 @@ fn build(cur: &Body, sel_pts: &[(Vec3, f64)], round: bool) -> Result<Body> {
|
||||
let wires: Vec<mt::Wire> = p.boundaries().iter().map(|w| w.inverse()).collect();
|
||||
p = mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("corner patch: {e}")))?;
|
||||
}
|
||||
rec.blend(&p, corner);
|
||||
new_faces.push(p);
|
||||
}
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_faces.into();
|
||||
}
|
||||
let out = Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("fillet produced an invalid solid: {e}")))?)?;
|
||||
Ok(out)
|
||||
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("fillet produced an invalid solid: {e}")))?;
|
||||
crate::record::rebuilt(out, &[&solid], &rec)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,9 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
|
||||
subst.insert(e.id(), ne);
|
||||
}
|
||||
}
|
||||
// The side edges at the ends are pieces of themselves; the contacts and end arcs are new.
|
||||
let mut rec = crate::record::Rebuild::default();
|
||||
rec.pieces(&subst);
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let nf = if i == fr.i1 {
|
||||
@@ -246,6 +249,7 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
rec.face(&nf, f);
|
||||
new_faces.push(nf);
|
||||
}
|
||||
// The blend surface: rational quadratic arcs (tangent points and the edge point, middle
|
||||
@@ -279,12 +283,14 @@ pub fn fillet_variable_points(body: &Body, edge_at: Vec3, start: Vec3, points: &
|
||||
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
|
||||
};
|
||||
let blend = mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?;
|
||||
rec.blend(&blend, [edge.id()]);
|
||||
new_faces.push(blend);
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_faces.into();
|
||||
}
|
||||
Body::new(Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?)
|
||||
let out = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("variable fillet produced an invalid solid: {e}")))?;
|
||||
crate::record::rebuilt(out, &[solid], &rec)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -312,9 +318,11 @@ pub fn fillet_chord(body: &Body, edges: &[Vec3], chord: f64) -> Result<Body> {
|
||||
None => groups.push((r, vec![*p])),
|
||||
}
|
||||
}
|
||||
let mut cur = body.clone();
|
||||
// Each step's result carries its history over `[body]`.
|
||||
let counts = crate::record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::operand(body, 0, &counts);
|
||||
for (r, pts) in groups {
|
||||
cur = crate::fillet(&cur, &pts, r)?;
|
||||
cur = crate::record::after(crate::fillet(&cur, &pts, r)?, &cur, &counts);
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
+180
-55
@@ -33,6 +33,11 @@ pub struct Body {
|
||||
pub(crate) splits: Option<std::sync::Arc<Vec<Vec<Vec3>>>>,
|
||||
/// Exact construction/import source identities, in this body's face order.
|
||||
pub(crate) face_sources: Option<std::sync::Arc<Vec<Option<FaceSource>>>>,
|
||||
/// Sticky transient veto: authored face boundaries must survive later rebuilds.
|
||||
pub(crate) regularization_protected: bool,
|
||||
/// Exact history of the operation that made this body, relative to that operation's
|
||||
/// operands; `None` when the operation does not report one.
|
||||
pub(crate) history: Option<std::sync::Arc<crate::history::History>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -120,6 +125,11 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec<MeshedShell> {
|
||||
/// `None` when nothing needs splitting or the parameters don't match the surface.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3, Vec3); 3]>> {
|
||||
refined_with_cache(pm, surf, tol, true)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn refined_with_cache(pm: &PolygonMesh, surf: &mt::Surface, tol: f64, memoize: bool) -> Option<Vec<[(Vec3, Vec3); 3]>> {
|
||||
use mt::{ParametricSurface, ParametricSurface3D};
|
||||
let (pos, uvs, nor) = (pm.positions(), pm.uv_coords(), pm.normals());
|
||||
if uvs.is_empty() {
|
||||
@@ -148,6 +158,15 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
|
||||
// Edges on the face's boundary are shared with the next face: never split them.
|
||||
let k = |x: &V| ((x.1.0 * 1e9).round() as i64, (x.1.1 * 1e9).round() as i64);
|
||||
let ek = |a: &V, b: &V| if k(a) < k(b) { (k(a), k(b)) } else { (k(b), k(a)) };
|
||||
// Only exact finite values identify reusable evaluations. In particular,
|
||||
// the rounded UV keys used for boundary incidence cannot identify a point
|
||||
// whose derivatives or midpoint may be reused.
|
||||
let vertex_key = |v: &V| {
|
||||
let values = [v.0.x, v.0.y, v.0.z, v.1.0, v.1.1, v.2.x, v.2.y, v.2.z];
|
||||
values.iter().all(|x| x.is_finite()).then(|| values.map(f64::to_bits))
|
||||
};
|
||||
let cache_limit = cap * 3;
|
||||
let mut singular_cache = std::collections::HashMap::<(u64, u64), bool>::new();
|
||||
let mut changed = false;
|
||||
for _ in 0..4 {
|
||||
let mut uses: std::collections::HashMap<((i64, i64), (i64, i64)), u32> = std::collections::HashMap::new();
|
||||
@@ -156,34 +175,60 @@ fn refined(pm: &PolygonMesh, surf: &mt::Surface, tol: f64) -> Option<Vec<[(Vec3,
|
||||
*uses.entry(e).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let at = |a: &V, b: &V| -> Option<V> {
|
||||
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
|
||||
return None;
|
||||
let mut edge_cache = std::collections::HashMap::<([u64; 8], [u64; 8]), Option<V>>::new();
|
||||
let mut at = |a: &V, b: &V| -> Option<V> {
|
||||
let key = if memoize { vertex_key(a).zip(vertex_key(b)).map(|(a, b)| if a < b { (a, b) } else { (b, a) }) } else { None };
|
||||
if let Some(key) = key
|
||||
&& let Some(value) = edge_cache.get(&key)
|
||||
{
|
||||
return *value;
|
||||
}
|
||||
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
|
||||
let singular = |x: &V| {
|
||||
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
|
||||
let c = du.cross(dv);
|
||||
c.x * c.x + c.y * c.y + c.z * c.z
|
||||
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300
|
||||
let mut calculate = || -> Option<V> {
|
||||
if uses.get(&ek(a, b)).copied().unwrap_or(0) < 2 {
|
||||
return None;
|
||||
}
|
||||
// Not at a degenerate point (a cone's apex: its parameters there are arbitrary).
|
||||
let mut singular = |x: &V| {
|
||||
let key = (x.1.0.to_bits(), x.1.1.to_bits());
|
||||
let cacheable = memoize && x.1.0.is_finite() && x.1.1.is_finite();
|
||||
if cacheable && let Some(value) = singular_cache.get(&key) {
|
||||
return *value;
|
||||
}
|
||||
let (du, dv) = (surf.uder(x.1.0, x.1.1), surf.vder(x.1.0, x.1.1));
|
||||
let c = du.cross(dv);
|
||||
let value = c.x * c.x + c.y * c.y + c.z * c.z
|
||||
<= 1e-12 * (du.x * du.x + du.y * du.y + du.z * du.z + dv.x * dv.x + dv.y * dv.y + dv.z * dv.z).powi(2) + 1e-300;
|
||||
if cacheable && singular_cache.len() < cache_limit {
|
||||
singular_cache.insert(key, value);
|
||||
}
|
||||
value
|
||||
};
|
||||
if singular(a) || singular(b) {
|
||||
return None;
|
||||
}
|
||||
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
|
||||
let q = from_p3(surf.subs(u, v));
|
||||
// Within tolerance, or so far off that the parameters wrapped (a seam).
|
||||
let sag = q.dist((a.0 + b.0) * 0.5);
|
||||
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
|
||||
return None;
|
||||
}
|
||||
let n = surf.normal(u, v);
|
||||
let mut n = Vec3::new(n.x, n.y, n.z);
|
||||
// Keep the mesh's normal side.
|
||||
if n.dot(a.2 + b.2) < 0.0 {
|
||||
n = -n;
|
||||
}
|
||||
Some((q, (u, v), n))
|
||||
};
|
||||
if singular(a) || singular(b) {
|
||||
return None;
|
||||
let value = calculate();
|
||||
if let Some(key) = key
|
||||
&& edge_cache.len() < cache_limit
|
||||
&& value.is_none_or(|v| vertex_key(&v).is_some())
|
||||
{
|
||||
edge_cache.insert(key, value);
|
||||
}
|
||||
let (u, v) = ((a.1.0 + b.1.0) / 2.0, (a.1.1 + b.1.1) / 2.0);
|
||||
let q = from_p3(surf.subs(u, v));
|
||||
// Within tolerance, or so far off that the parameters wrapped (a seam).
|
||||
let sag = q.dist((a.0 + b.0) * 0.5);
|
||||
if sag <= tol || sag >= 0.5 * a.0.dist(b.0) {
|
||||
return None;
|
||||
}
|
||||
let n = surf.normal(u, v);
|
||||
let mut n = Vec3::new(n.x, n.y, n.z);
|
||||
// Keep the mesh's normal side.
|
||||
if n.dot(a.2 + b.2) < 0.0 {
|
||||
n = -n;
|
||||
}
|
||||
Some((q, (u, v), n))
|
||||
value
|
||||
};
|
||||
let mut next = Vec::with_capacity(tris.len());
|
||||
let mut any = false;
|
||||
@@ -292,6 +337,31 @@ pub(crate) fn from_p3(p: mt::Point3) -> Vec3 {
|
||||
Vec3::new(p.x, p.y, p.z)
|
||||
}
|
||||
|
||||
/// The distinct edges of `faces` in order of first use, and the faces (by position) each one
|
||||
/// bounds: the one edge order of a body, used by [`Body::edges`], tessellations and
|
||||
/// [`crate::history`].
|
||||
pub(crate) fn edge_order<'a, P: 'a, C: 'a, S: 'a>(
|
||||
faces: impl IntoIterator<Item = &'a truck_topology::Face<P, C, S>>,
|
||||
) -> (Vec<truck_topology::Edge<P, C>>, Vec<Vec<usize>>) {
|
||||
let mut index = std::collections::HashMap::new();
|
||||
let (mut edges, mut bounds): (Vec<truck_topology::Edge<P, C>>, Vec<Vec<usize>>) = (Vec::new(), Vec::new());
|
||||
for (fi, f) in faces.into_iter().enumerate() {
|
||||
for e in f.edge_iter() {
|
||||
let i = *index.entry(e.id()).or_insert_with(|| {
|
||||
edges.push(e.clone());
|
||||
bounds.push(Vec::new());
|
||||
edges.len() - 1
|
||||
});
|
||||
if let Some(slot) = bounds.get_mut(i)
|
||||
&& !slot.contains(&fi)
|
||||
{
|
||||
slot.push(fi);
|
||||
}
|
||||
}
|
||||
}
|
||||
(edges, bounds)
|
||||
}
|
||||
|
||||
fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum();
|
||||
let mut acc = 0.0;
|
||||
@@ -308,7 +378,9 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
}
|
||||
|
||||
impl Body {
|
||||
/// The very same body (shared data), not just an equal-looking one.
|
||||
/// The very same body (shared data), not just an equal-looking one. Its history (how an
|
||||
/// operation made it) is not part of it: an operation that leaves a body as it is (a cut by
|
||||
/// a tool clear of it) gives the same body, with that operation's history.
|
||||
pub fn same(&self, other: &Body) -> bool {
|
||||
std::sync::Arc::ptr_eq(&self.solid, &other.solid)
|
||||
&& match (&self.mesh, &other.mesh) {
|
||||
@@ -320,6 +392,7 @@ impl Body {
|
||||
&& self.paint == other.paint
|
||||
&& self.splits == other.splits
|
||||
&& self.face_sources == other.face_sources
|
||||
&& self.regularization_protected == other.regularization_protected
|
||||
}
|
||||
|
||||
pub(crate) fn new(solid: Solid) -> Result<Body> {
|
||||
@@ -358,9 +431,58 @@ impl Body {
|
||||
paint: None,
|
||||
splits: None,
|
||||
face_sources: None,
|
||||
regularization_protected: false,
|
||||
history: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
self.regularization_protected |= input.regularization_protected;
|
||||
self
|
||||
}
|
||||
|
||||
/// Exact history of the operation that made this body, relative to that operation's
|
||||
/// operands (see [`crate::history`]); `None` when the operation does not report one.
|
||||
pub fn history(&self) -> Option<&crate::history::History> {
|
||||
self.history.as_deref()
|
||||
}
|
||||
|
||||
/// Attach the history of the operation that made this body from `operands`, after checking
|
||||
/// it describes this body.
|
||||
pub(crate) fn with_history(mut self, history: crate::history::History, operands: &[&Body]) -> Result<Self> {
|
||||
history.check(&self, operands)?;
|
||||
self.history = Some(std::sync::Arc::new(history));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// [`Body::with_history`], given the operands' face and edge counts.
|
||||
pub(crate) fn with_history_counts(mut self, history: crate::history::History, operands: &[(usize, usize)]) -> Result<Self> {
|
||||
history.check_counts((self.face_count(), self.edge_count()), operands)?;
|
||||
self.history = Some(std::sync::Arc::new(history));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// This body with `history` if it describes the body made from operands with these face
|
||||
/// and edge counts, else with none: an operation whose history could not be recorded still
|
||||
/// gives its result.
|
||||
pub(crate) fn with_checked_history(mut self, history: Option<crate::history::History>, operands: &[(usize, usize)]) -> Self {
|
||||
let counts = (self.face_count(), self.edge_count());
|
||||
self.history = history.and_then(|h| match h.check_counts(counts, operands) {
|
||||
Ok(()) => Some(std::sync::Arc::new(h)),
|
||||
Err(e) => {
|
||||
log::debug!("no history: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// This body with no history: an operation whose history is not known.
|
||||
pub(crate) fn without_history(mut self) -> Self {
|
||||
self.history = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn face_sources(&self) -> Option<&[Option<FaceSource>]> {
|
||||
self.face_sources.as_deref().map(Vec::as_slice)
|
||||
}
|
||||
@@ -588,6 +710,7 @@ impl Body {
|
||||
/// The same body with the lines its faces were split along on purpose.
|
||||
pub fn with_split_lines(mut self, s: Option<std::sync::Arc<Vec<Vec<Vec3>>>>) -> Body {
|
||||
self.splits = s.filter(|l| !l.is_empty());
|
||||
self.regularization_protected |= self.splits.is_some();
|
||||
self
|
||||
}
|
||||
|
||||
@@ -612,6 +735,7 @@ impl Body {
|
||||
})
|
||||
.filter(|p| p.opacity < 1.0 || !p.faces.is_empty())
|
||||
.map(std::sync::Arc::new);
|
||||
self.regularization_protected |= self.paint().is_some_and(|paint| !paint.faces.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -658,11 +782,15 @@ impl Body {
|
||||
let flip = !face.orientation();
|
||||
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
|
||||
// Curved faces: split triangles that cut through the surface.
|
||||
if refine
|
||||
&& let Some(sf) = solid_faces.get(fi)
|
||||
&& !matches!(sf.surface(), mt::Surface::Plane(_))
|
||||
&& let Some(tris) = refined(&pm, &sf.surface(), tol)
|
||||
{
|
||||
let refinement = if refine {
|
||||
solid_faces.get(fi).and_then(|sf| {
|
||||
let surface = sf.surface();
|
||||
if matches!(surface, mt::Surface::Plane(_)) { None } else { refined(&pm, &surface, tol) }
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(tris) = refinement {
|
||||
let fid = u32::try_from(fi).unwrap_or(u32::MAX);
|
||||
for t in tris {
|
||||
let mut idx = [0u32; 3];
|
||||
@@ -713,27 +841,9 @@ impl Body {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for e in meshed.iter().flat_map(|s| s.edge_iter()) {
|
||||
if seen.contains_key(&e.id()) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(e.id(), out.edges.len());
|
||||
let c = e.oriented_curve();
|
||||
out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect());
|
||||
}
|
||||
// Which faces each edge bounds.
|
||||
out.edge_faces = vec![Vec::new(); out.edges.len()];
|
||||
for (fi, face) in meshed.iter().flat_map(|s| s.face_iter()).enumerate() {
|
||||
for e in face.edge_iter() {
|
||||
if let Some(slot) = seen.get(&e.id()).and_then(|i| out.edge_faces.get_mut(*i)) {
|
||||
let f = u32::try_from(fi).unwrap_or(u32::MAX);
|
||||
if !slot.contains(&f) {
|
||||
slot.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (edges, faces) = edge_order(meshed.iter().flat_map(|s| s.face_iter()));
|
||||
out.edges = edges.iter().map(|e| e.oriented_curve().0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect()).collect();
|
||||
out.edge_faces = faces.into_iter().map(|fs| fs.into_iter().map(|f| u32::try_from(f).unwrap_or(u32::MAX)).collect()).collect();
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
@@ -895,9 +1005,16 @@ impl Body {
|
||||
|
||||
impl Body {
|
||||
/// The separate pieces of a body: each outer shell with the voids inside it. A body in one
|
||||
/// piece comes back as itself.
|
||||
/// piece comes back as itself. Pieces of a body in several carry their history over
|
||||
/// `[self]` (their faces and edges are its own), so whoever names them can follow it.
|
||||
pub fn lumps(&self) -> Result<Vec<Body>> {
|
||||
self.lumps_with_clearance(0.0)
|
||||
let pieces = self.lumps_with_clearance(0.0)?;
|
||||
if pieces.len() < 2 {
|
||||
return Ok(pieces);
|
||||
}
|
||||
let counts = [(self.face_count(), self.edge_count())];
|
||||
let histories = crate::boolean_record::parts_of(&pieces, self);
|
||||
Ok(pieces.into_iter().zip(histories).map(|(piece, history)| piece.with_checked_history(history, &counts)).collect())
|
||||
}
|
||||
|
||||
/// Whether this body and `other` share material or touch (within a fine mesh tolerance).
|
||||
@@ -1019,7 +1136,7 @@ impl Body {
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)))
|
||||
Ok(result.with_face_sources(sources.map(std::sync::Arc::new)).inherit_regularization_protection(self))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -1029,3 +1146,11 @@ impl Body {
|
||||
#[cfg(test)]
|
||||
#[path = "body_lump_tests.rs"]
|
||||
mod lump_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "refine_cache_tests.rs"]
|
||||
mod refine_cache_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "regularization_protection_tests.rs"]
|
||||
mod regularization_protection_tests;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Scoped reports for successful Boolean retries. Discarded attempts never enter
|
||||
//! their caller's report, and independent evaluations cannot inherit old events.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
const MAX_EVENTS: usize = 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BooleanStrategy {
|
||||
RefinedTolerance,
|
||||
ShiftedOperands,
|
||||
CoplanarSeparation,
|
||||
CommonBore,
|
||||
PlanarFallback,
|
||||
ToolNudge,
|
||||
Piecewise,
|
||||
}
|
||||
|
||||
/// Numerical adjustments used by a successful retry, in millimetres/radians.
|
||||
/// Operand shifts are reversed on the result; face shifts are temporary working
|
||||
/// offsets. A tool nudge, in contrast, changes the relative operand placement.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
pub struct BooleanFallback {
|
||||
pub strategy: BooleanStrategy,
|
||||
pub translation_mm: f64,
|
||||
pub rotation_rad: f64,
|
||||
pub face_shift_mm: f64,
|
||||
pub tolerance_mm: f64,
|
||||
}
|
||||
|
||||
impl BooleanFallback {
|
||||
pub(crate) fn new(strategy: BooleanStrategy) -> Self {
|
||||
Self { strategy, translation_mm: 0., rotation_rad: 0., face_shift_mm: 0., tolerance_mm: 0. }
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
match self.strategy {
|
||||
BooleanStrategy::RefinedTolerance => format!("refined tolerance {:.6e} mm", self.tolerance_mm),
|
||||
BooleanStrategy::ShiftedOperands => {
|
||||
format!("shifted operands {:.6e} mm (result shifted back), tolerance {:.6e} mm", self.translation_mm, self.tolerance_mm)
|
||||
}
|
||||
BooleanStrategy::CoplanarSeparation => format!("temporary coincident-face separation up to {:.6e} mm per face", self.face_shift_mm),
|
||||
BooleanStrategy::CommonBore => format!("temporary bore offset {:.6e} mm, followed by recutting the original bore", self.face_shift_mm),
|
||||
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
|
||||
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
|
||||
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct BooleanDiagnostics {
|
||||
pub events: Vec<BooleanFallback>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl BooleanDiagnostics {
|
||||
pub(crate) fn push(&mut self, event: BooleanFallback) {
|
||||
if self.events.len() < MAX_EVENTS {
|
||||
self.events.push(event);
|
||||
} else {
|
||||
self.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warning(&self) -> Option<String> {
|
||||
if self.events.is_empty() && !self.truncated {
|
||||
return None;
|
||||
}
|
||||
let mut descriptions: Vec<String> = self.events.iter().take(4).map(BooleanFallback::description).collect();
|
||||
if self.events.len() > 4 {
|
||||
descriptions.push(format!("{} additional retries", self.events.len() - 4));
|
||||
}
|
||||
if self.truncated {
|
||||
descriptions.push(format!("report capped at {MAX_EVENTS} retries"));
|
||||
}
|
||||
Some(format!("Boolean retry: {}", descriptions.join("; ")))
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static REPORT: RefCell<Option<BooleanDiagnostics>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Scope {
|
||||
previous: Option<Option<BooleanDiagnostics>>,
|
||||
}
|
||||
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.previous.take() {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut() {
|
||||
*report = previous;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture only the events produced by this call. Nested callers explicitly merge
|
||||
/// an accepted attempt; dropping a scope restores its parent even during unwinding.
|
||||
pub fn capture_boolean_diagnostics<T>(run: impl FnOnce() -> T) -> (T, BooleanDiagnostics) {
|
||||
let previous = REPORT.with(|cell| cell.try_borrow_mut().ok().map(|mut report| report.replace(BooleanDiagnostics::default())));
|
||||
let scope = Scope { previous };
|
||||
let result = run();
|
||||
let report = REPORT.with(|cell| cell.try_borrow_mut().ok().and_then(|mut report| report.take())).unwrap_or_default();
|
||||
drop(scope);
|
||||
(result, report)
|
||||
}
|
||||
|
||||
pub(crate) fn record(event: BooleanFallback) {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut()
|
||||
&& let Some(report) = report.as_mut()
|
||||
{
|
||||
report.push(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn merge(report: BooleanDiagnostics) {
|
||||
for event in report.events {
|
||||
record(event);
|
||||
}
|
||||
if report.truncated {
|
||||
REPORT.with(|cell| {
|
||||
if let Ok(mut report) = cell.try_borrow_mut()
|
||||
&& let Some(report) = report.as_mut()
|
||||
{
|
||||
report.truncated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nested_attempts_are_committed_explicitly_and_do_not_leak() {
|
||||
let (_, report) = capture_boolean_diagnostics(|| {
|
||||
let (_, discarded) = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge)));
|
||||
assert_eq!(discarded.events.len(), 1);
|
||||
let (_, accepted) = capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::PlanarFallback)));
|
||||
merge(accepted);
|
||||
});
|
||||
assert_eq!(report.events.len(), 1);
|
||||
assert_eq!(report.events[0].strategy, BooleanStrategy::PlanarFallback);
|
||||
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
|
||||
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
|
||||
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_operations_and_failed_calls_have_no_retry_report() {
|
||||
use solvecraft_geom::Vec3;
|
||||
let body = crate::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap();
|
||||
let other = crate::box_solid(Vec3::new(5., 0., 0.), Vec3::new(15., 10., 10.)).unwrap();
|
||||
let (result, diagnostics) = capture_boolean_diagnostics(|| crate::boolean(&body, &other, crate::BoolOp::Union));
|
||||
assert!(result.unwrap().is_some());
|
||||
assert!(diagnostics.warning().is_none(), "{diagnostics:?}");
|
||||
let mesh = crate::mesh_body(&[Vec3::ZERO, Vec3::X, Vec3::Y], &[[0, 1, 2]]).unwrap();
|
||||
let (result, diagnostics) = capture_boolean_diagnostics(|| crate::boolean(&body, &mesh, crate::BoolOp::Cut));
|
||||
assert!(result.is_err());
|
||||
assert!(diagnostics.warning().is_none(), "{diagnostics:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwinding_restores_parent_and_reports_are_thread_local_and_bounded() {
|
||||
let (_, report) = capture_boolean_diagnostics(|| {
|
||||
let failed = crate::guard("diagnostic test", || {
|
||||
capture_boolean_diagnostics(|| -> crate::Result<()> {
|
||||
record(BooleanFallback::new(BooleanStrategy::ToolNudge));
|
||||
panic!("discard this attempt");
|
||||
})
|
||||
.0
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let separate = std::thread::spawn(|| capture_boolean_diagnostics(|| record(BooleanFallback::new(BooleanStrategy::ToolNudge))).1);
|
||||
assert_eq!(separate.join().unwrap().events.len(), 1);
|
||||
for _ in 0..MAX_EVENTS + 1 {
|
||||
record(BooleanFallback::new(BooleanStrategy::PlanarFallback));
|
||||
}
|
||||
});
|
||||
assert_eq!(report.events.len(), MAX_EVENTS);
|
||||
assert!(report.truncated);
|
||||
assert!(report.events.iter().all(|event| event.strategy == BooleanStrategy::PlanarFallback));
|
||||
assert!(report.warning().unwrap().contains("capped"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Booleans' histories, path by path: every face and edge origin checked against the geometry
|
||||
//! of the operands (in tests only: the history itself is recorded, never sampled), and the exact
|
||||
//! origins asserted where the outcome is known.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::history::{Change, EdgeOrigin, FaceOrigin, History, Side};
|
||||
use crate::{BoolOp, boolean, box_solid};
|
||||
|
||||
const fn face(operand: usize, face: usize) -> FaceOrigin {
|
||||
FaceOrigin::Face { operand, face }
|
||||
}
|
||||
|
||||
fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
|
||||
// Ericson, Real-Time Collision Detection, 5.1.5.
|
||||
let (ab, ac, ap) = (b - a, c - a, p - a);
|
||||
let (d1, d2) = (ab.dot(ap), ac.dot(ap));
|
||||
if d1 <= 0.0 && d2 <= 0.0 {
|
||||
return a;
|
||||
}
|
||||
let bp = p - b;
|
||||
let (d3, d4) = (ab.dot(bp), ac.dot(bp));
|
||||
if d3 >= 0.0 && d4 <= d3 {
|
||||
return b;
|
||||
}
|
||||
let vc = d1 * d4 - d3 * d2;
|
||||
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
|
||||
return a + ab * (d1 / (d1 - d3));
|
||||
}
|
||||
let cp = p - c;
|
||||
let (d5, d6) = (ab.dot(cp), ac.dot(cp));
|
||||
if d6 >= 0.0 && d5 <= d6 {
|
||||
return c;
|
||||
}
|
||||
let vb = d5 * d2 - d1 * d6;
|
||||
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
|
||||
return a + ac * (d2 / (d2 - d6));
|
||||
}
|
||||
let va = d3 * d6 - d5 * d4;
|
||||
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
|
||||
return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
|
||||
}
|
||||
let denom = 1.0 / (va + vb + vc);
|
||||
a + ab * (vb * denom) + ac * (vc * denom)
|
||||
}
|
||||
|
||||
/// Distance from `p` to face `f` of a mesh.
|
||||
pub(crate) fn to_face(m: &Mesh, f: usize, p: Vec3) -> f64 {
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, x)| **x as usize == f)
|
||||
.map(|(t, _)| {
|
||||
let [a, b, c] = t.map(|i| m.positions[i as usize]);
|
||||
closest_on_triangle(p, a, b, c).dist(p)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Distance from `p` to edge `e` of a mesh.
|
||||
pub(crate) fn to_edge(m: &Mesh, e: usize, p: Vec3) -> f64 {
|
||||
m.edges[e].windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// How far, in a check, a face or edge may lie from what it came from.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Reach {
|
||||
/// A face made in place of an edge, or an offset of a face, lies within this of it.
|
||||
pub made: f64,
|
||||
/// A face or edge the operation moved lies within this of the one it came from.
|
||||
pub moved: f64,
|
||||
}
|
||||
|
||||
/// The result's history, checked against it and its operands, with every origin checked
|
||||
/// against the geometry: each face's triangles lie on its origin faces, each origin face holds
|
||||
/// some of them; each edge lies along its origin edges, or on both faces of a meeting. `tol`:
|
||||
/// the meshes' tolerance.
|
||||
fn verified<'a>(r: &'a Body, operands: &[&Body], tol: f64) -> &'a History {
|
||||
checked(r, operands, tol, Reach { made: 0.0, moved: 0.0 }, &|_| false)
|
||||
}
|
||||
|
||||
/// [`verified`] for any operation: a face lies on its origin faces (within `at.moved` where the
|
||||
/// operation moves faces), within `at.made` of the edges it is made in place of and of the faces
|
||||
/// it is an offset of; an edge lies along its origin edges (within `at.moved`). Faces from
|
||||
/// nothing are allowed only where `generated` says.
|
||||
pub(crate) fn checked<'a>(r: &'a Body, operands: &[&Body], tol: f64, at: Reach, generated: &dyn Fn(usize) -> bool) -> &'a History {
|
||||
let h = r.history().expect("the operation reports its history");
|
||||
h.check(r, operands).unwrap();
|
||||
let rm = r.tessellate(tol).unwrap();
|
||||
let ms: Vec<Mesh> = operands.iter().map(|b| b.tessellate(tol).unwrap()).collect();
|
||||
let near = 4.0 * tol + r.size() * 1e-6;
|
||||
let on = |o: &FaceOrigin, p: Vec3| match *o {
|
||||
FaceOrigin::Face { operand, face } => to_face(&ms[operand], face, p) < near.max(at.moved),
|
||||
FaceOrigin::Edge { operand, edge } => to_edge(&ms[operand], edge, p) < at.made + near,
|
||||
FaceOrigin::Offset { operand, face } => to_face(&ms[operand], face, p) < at.made + near,
|
||||
_ => false,
|
||||
};
|
||||
let along = |o: &EdgeOrigin, p: &Vec3| match o {
|
||||
EdgeOrigin::Edge { operand, edge } => to_edge(&ms[*operand], *edge, *p) < near.max(at.moved),
|
||||
EdgeOrigin::Meet { a, b } => a.origins().iter().any(|o| on(o, *p)) && b.origins().iter().any(|o| on(o, *p)),
|
||||
};
|
||||
for (i, f) in h.faces.iter().enumerate() {
|
||||
assert!(!f.origins.is_empty() || generated(i), "face {i} has no origin");
|
||||
let centres = centres(&rm, i);
|
||||
for c in centres.iter().filter(|_| !f.origins.is_empty()) {
|
||||
assert!(f.origins.iter().any(|o| on(o, *c)), "face {i} ({f:?}) at {c:?} lies on none of its origins");
|
||||
}
|
||||
for o in &f.origins {
|
||||
assert!(centres.iter().any(|c| on(o, *c)), "face {i}: origin {o:?} is under none of it");
|
||||
}
|
||||
}
|
||||
for (j, e) in h.edges.iter().enumerate() {
|
||||
let pts = &rm.edges[j];
|
||||
for o in &e.origins {
|
||||
match o {
|
||||
// A joined edge runs along all of its origins, each along part of it (one in the
|
||||
// middle of a straight edge holds none of its points, but its own lie on it).
|
||||
EdgeOrigin::Edge { operand, edge } if e.origins.len() > 1 => assert!(
|
||||
pts.iter().any(|p| along(o, p)) || ms[*operand].edges[*edge].iter().any(|q| to_edge(&rm, j, *q) < near.max(at.moved)),
|
||||
"edge {j}: {o:?} is not along it"
|
||||
),
|
||||
_ => assert!(pts.iter().all(|p| along(o, p)), "edge {j} ({e:?}) does not lie along {o:?}"),
|
||||
}
|
||||
}
|
||||
if e.origins.len() > 1 {
|
||||
for p in pts {
|
||||
assert!(e.origins.iter().any(|o| along(o, p)), "edge {j}: {p:?} lies along none of {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The centres of face `f`'s triangles.
|
||||
pub(crate) fn centres(m: &Mesh, f: usize) -> Vec<Vec3> {
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, x)| **x as usize == f)
|
||||
.map(|(t, _)| t.map(|k| m.positions[k as usize]).iter().fold(Vec3::ZERO, |s, p| s + *p) * (1.0 / 3.0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many faces of each change.
|
||||
pub(crate) fn changes(h: &History) -> [usize; 5] {
|
||||
let mut n = [0; 5];
|
||||
for f in &h.faces {
|
||||
n[match f.change {
|
||||
Change::Kept => 0,
|
||||
Change::Modified => 1,
|
||||
Change::Split => 2,
|
||||
Change::Merged => 3,
|
||||
Change::Generated => 4,
|
||||
}] += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// The faces of the result that come from operand face `o`.
|
||||
fn from(h: &History, o: FaceOrigin) -> Vec<usize> {
|
||||
(0..h.faces.len()).filter(|i| h.faces[*i].origins.contains(&o)).collect()
|
||||
}
|
||||
|
||||
/// The meetings among the edges' origins, as face pairs (each side one face here).
|
||||
fn meets(h: &History) -> BTreeSet<(FaceOrigin, FaceOrigin)> {
|
||||
let one = |s: &Side| s.single().expect("a side of one face");
|
||||
h.edges.iter().flat_map(|e| &e.origins).filter_map(|o| if let EdgeOrigin::Meet { a, b } = o { Some((one(a), one(b))) } else { None }).collect()
|
||||
}
|
||||
|
||||
fn boxed(x0: f64, y0: f64, z0: f64, x1: f64, y1: f64, z1: f64) -> Body {
|
||||
box_solid(Vec3::new(x0, y0, z0), Vec3::new(x1, y1, z1)).unwrap()
|
||||
}
|
||||
|
||||
/// A short summary of a history for failure messages.
|
||||
fn show(h: &History) -> String {
|
||||
let faces: Vec<String> = h.faces.iter().enumerate().map(|(i, f)| format!("{i}: {:?} {:?}", f.change, f.origins)).collect();
|
||||
let edges: Vec<String> = h.edges.iter().enumerate().map(|(i, e)| format!("{i}: {:?} {:?}", e.faces, e.origins)).collect();
|
||||
format!("faces\n{}\nedges\n{}", faces.join("\n"), edges.join("\n"))
|
||||
}
|
||||
|
||||
/// Each face's origins and change, in face order.
|
||||
fn faces_of(h: &History) -> Vec<(Change, Vec<FaceOrigin>)> {
|
||||
h.faces.iter().map(|f| (f.change, f.origins.clone())).collect()
|
||||
}
|
||||
|
||||
/// Truck's boolean with a sphere (no prism, no coincident faces): a box with a corner taken
|
||||
/// off by a sphere. The three faces at the corner get a notch, the sphere's patches inside the
|
||||
/// box bound it, meeting the box's faces along new edges.
|
||||
#[test]
|
||||
fn box_minus_sphere_through_truck() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
|
||||
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.02);
|
||||
assert_eq!(changes(h), [3, 6, 0, 0, 0], "{}", show(h));
|
||||
let from_b: BTreeSet<usize> = h
|
||||
.faces
|
||||
.iter()
|
||||
.flat_map(|f| &f.origins)
|
||||
.filter_map(|o| match o {
|
||||
FaceOrigin::Face { operand: 1, face } => Some(*face),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(from_b.len(), 3);
|
||||
// Each notched box face meets two of the sphere's patches.
|
||||
assert_eq!(meets(h).len(), 6, "{}", show(h));
|
||||
assert!(meets(h).iter().all(|(x, y)| matches!((x, y), (FaceOrigin::Face { operand: 0, .. }, FaceOrigin::Face { operand: 1, .. }))));
|
||||
}
|
||||
|
||||
/// The jitter retries move both operands and the result back: the history holds for the
|
||||
/// operands themselves, the same faces coming from the same operand faces.
|
||||
#[test]
|
||||
fn shifted_truck_attempt_keeps_the_history() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
|
||||
let counts = crate::record::counts(&[&a, &b]);
|
||||
let mut compared = 0;
|
||||
for op in [BoolOp::Cut, BoolOp::Union, BoolOp::Intersect] {
|
||||
let Ok((_, Some((plain, _)))) = crate::ops::truck_boolean(&a, &b, op, None, 0.035) else { continue };
|
||||
// The shifts the retries use (a hundredth of the size along each).
|
||||
for j in [[0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]] {
|
||||
let shift = truck_modeling::Vector3::new(j[0], j[1], j[2]) * 0.17;
|
||||
let Ok((s, h)) = crate::ops::truck_boolean(&a, &b, op, Some(shift), 0.035) else { continue };
|
||||
let faces = h.as_ref().map(|(f, _)| f.clone());
|
||||
assert_eq!(faces, Some(plain.clone()), "{op:?}");
|
||||
let r = crate::boolean_record::truck_result(s, h, 17.4, &[], &counts).unwrap();
|
||||
verified(&r, &[&a, &b], 0.02);
|
||||
compared += 1;
|
||||
}
|
||||
}
|
||||
assert!(compared > 0);
|
||||
}
|
||||
|
||||
/// Two prisms along x, the tool's section inside the target's, overlapping in part: sewn slab by
|
||||
/// slab, then healed. The target's face at x = 10 gets the tool's outline as a hole, which the
|
||||
/// tool's sides meet; the tool's sides and far end stand out of it.
|
||||
#[test]
|
||||
fn box_union_box_prism() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = boxed(5.0, 2.0, 2.0, 15.0, 8.0, 8.0);
|
||||
let r = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.02);
|
||||
assert_eq!(h.faces.len(), 11);
|
||||
let mut origins: Vec<FaceOrigin> = h
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[o] => *o,
|
||||
_ => panic!("one origin each: {}", show(h)),
|
||||
})
|
||||
.collect();
|
||||
origins.sort();
|
||||
// All of a's faces, and b's but the end inside a.
|
||||
let mut want: Vec<FaceOrigin> = (0..6).map(|i| face(0, i)).chain((0..6).map(|i| face(1, i))).collect();
|
||||
let inside = face(1, 4);
|
||||
want.retain(|o| *o != inside);
|
||||
assert_eq!(origins, want, "{}", show(h));
|
||||
// b's four sides meet a's face at x = 10.
|
||||
let rim: BTreeSet<_> = meets(h).iter().map(|(x, _)| *x).collect();
|
||||
assert_eq!(meets(h).len(), 4);
|
||||
assert_eq!(rim.len(), 1);
|
||||
assert!(h.edges.iter().all(|e| e.origins.len() == 1));
|
||||
}
|
||||
|
||||
/// A through hole: the box's sides are kept, top and bottom get the hole, the cylinder's two
|
||||
/// half walls line it, meeting top and bottom along its rims, and meet each other along the
|
||||
/// cylinder's own seams.
|
||||
#[test]
|
||||
fn box_minus_cylinder_prism() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = crate::cylinder(Vec3::new(5.0, 5.0, -1.0), Vec3::Z, 2.0, 12.0).unwrap();
|
||||
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.01);
|
||||
assert_eq!(changes(h), [4, 4, 0, 0, 0], "{}", show(h));
|
||||
let walls = from(h, face(1, 1)).len() + from(h, face(1, 2)).len();
|
||||
assert_eq!(walls, 2, "{}", show(h));
|
||||
// Rims: each wall meets top and bottom.
|
||||
assert_eq!(meets(h).len(), 4, "{}", show(h));
|
||||
let seams = h.edges.iter().filter(|e| matches!(e.origins.as_slice(), [EdgeOrigin::Edge { operand: 1, .. }])).count();
|
||||
assert_eq!(seams, 2);
|
||||
// Every other edge is one of the box's twelve.
|
||||
let boxed_edges: BTreeSet<_> = h.edges.iter().flat_map(|e| &e.origins).filter(|o| matches!(o, EdgeOrigin::Edge { operand: 0, .. })).collect();
|
||||
assert_eq!(boxed_edges.len(), 12);
|
||||
}
|
||||
|
||||
/// A slot across the top (a stepped prism along y): the top and the box's ends come apart in
|
||||
/// pieces (Split); the slot's floor and sides are the tool's.
|
||||
#[test]
|
||||
fn slot_splits_a_face() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = boxed(4.0, -1.0, 5.0, 6.0, 11.0, 11.0);
|
||||
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.02);
|
||||
let top = (0..6).find(|i| a.planar_face(*i).is_some_and(|p| p.origin.z > 9.0 && p.normal().z > 0.9)).unwrap();
|
||||
let pieces = from(h, face(0, top));
|
||||
assert!(pieces.len() >= 2, "{}", show(h));
|
||||
assert!(pieces.iter().all(|i| h.faces[*i].change == Change::Split));
|
||||
// Three of the tool's faces bound the slot.
|
||||
let tool: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
|
||||
assert_eq!(tool.len(), 3, "{}", show(h));
|
||||
}
|
||||
|
||||
/// Bodies clear of each other: a union keeps both whole, a cut keeps the target.
|
||||
#[test]
|
||||
fn trivial_paths() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = crate::sphere(Vec3::new(30.0, 0.0, 0.0), 3.0).unwrap();
|
||||
let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
let h = verified(&u, &[&a, &b], 0.02);
|
||||
let want: Vec<(Change, Vec<FaceOrigin>)> =
|
||||
(0..a.face_count()).map(|i| (Change::Kept, vec![face(0, i)])).chain((0..b.face_count()).map(|i| (Change::Kept, vec![face(1, i)]))).collect();
|
||||
assert_eq!(faces_of(h), want);
|
||||
let c = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&c, &[&a, &b], 0.02);
|
||||
assert_eq!(faces_of(h), (0..a.face_count()).map(|i| (Change::Kept, vec![face(0, i)])).collect::<Vec<_>>());
|
||||
// Wholly inside: the intersection is the tool, the union the target.
|
||||
let inner = crate::sphere(Vec3::new(5.0, 5.0, 5.0), 2.0).unwrap();
|
||||
let i = boolean(&a, &inner, BoolOp::Intersect).unwrap().unwrap();
|
||||
let h = verified(&i, &[&a, &inner], 0.02);
|
||||
assert!(h.faces.iter().all(|f| f.change == Change::Kept && matches!(f.origins.as_slice(), [FaceOrigin::Face { operand: 1, .. }])));
|
||||
let u = boolean(&a, &inner, BoolOp::Union).unwrap().unwrap();
|
||||
let h = verified(&u, &[&a, &inner], 0.02);
|
||||
assert!(h.faces.iter().all(|f| matches!(f.origins.as_slice(), [FaceOrigin::Face { operand: 0, .. }])));
|
||||
// Cut out of the middle: a void, the tool's faces turned inward.
|
||||
let v = boolean(&a, &inner, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&v, &[&a, &inner], 0.02);
|
||||
assert_eq!(v.shell_count(), 2);
|
||||
assert_eq!(h.faces.len(), a.face_count() + inner.face_count());
|
||||
assert!(h.faces.iter().all(|f| f.change == Change::Kept));
|
||||
}
|
||||
|
||||
/// A body in two pieces, cut a piece at a time: each piece's faces are the body's own.
|
||||
#[test]
|
||||
fn by_lumps_composes_the_pieces() {
|
||||
let left = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let right = boxed(20.0, 0.0, 0.0, 30.0, 10.0, 10.0);
|
||||
let a = boolean(&left, &right, BoolOp::Union).unwrap().unwrap();
|
||||
assert_eq!(a.shell_count(), 2);
|
||||
let b = crate::sphere(Vec3::new(15.0, 5.0, 10.0), 7.0).unwrap();
|
||||
let r = crate::ops::by_lumps(&a, &b, a.lumps().unwrap(), b.lumps().unwrap(), BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.02);
|
||||
// Both pieces are bitten: the sphere's patches inside each line its bite.
|
||||
let bites: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
|
||||
assert!(bites.len() >= 2, "{}", show(h));
|
||||
let targets: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 0, .. })).collect();
|
||||
assert_eq!(targets.len(), 12);
|
||||
}
|
||||
|
||||
/// Two bodies touching along one whole face, back to back: glued, the two faces gone, the
|
||||
/// coplanar sides each side of the seam merged.
|
||||
#[test]
|
||||
fn glue_merges_across_the_seam() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = boxed(10.0, 0.0, 0.0, 20.0, 10.0, 10.0);
|
||||
let r = crate::coplanar::glue(&a, &b).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.02);
|
||||
assert_eq!(changes(h), [2, 0, 0, 4, 0], "{}", show(h));
|
||||
// The four seam edges went with the merged faces; the ends' edges are each box's.
|
||||
assert_eq!(h.edges.len(), 12);
|
||||
assert!(h.edges.iter().all(|e| !e.origins.is_empty()));
|
||||
}
|
||||
|
||||
/// Coincident faces pushed apart: a cut flush with the target's top, and a union of bodies
|
||||
/// standing on one floor (the floor that is cut back is both operands' floors).
|
||||
#[test]
|
||||
fn faces_pushed_apart() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let tool = boxed(2.0, 2.0, 5.0, 8.0, 8.0, 10.0);
|
||||
let r = crate::coplanar::boolean_apart(&a, &tool, BoolOp::Cut).unwrap().unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &tool], 0.02);
|
||||
// The pocket: its floor and four sides are the tool's.
|
||||
let tool_faces: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 1, .. })).collect();
|
||||
assert_eq!(tool_faces.len(), 5, "{}", show(h));
|
||||
let left = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let right = boxed(5.0, 3.0, 0.0, 15.0, 7.0, 4.0);
|
||||
let r = crate::coplanar::boolean_apart(&left, &right, BoolOp::Union).unwrap().unwrap().unwrap();
|
||||
let h = verified(&r, &[&left, &right], 0.02);
|
||||
assert!(h.faces.iter().any(|f| f.change == Change::Merged && f.origins.len() == 2), "{}", show(h));
|
||||
}
|
||||
|
||||
/// The exact polygon boolean: pieces of planar faces joined back into faces.
|
||||
#[test]
|
||||
fn planar_fallback() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = boxed(5.0, 5.0, 5.0, 15.0, 15.0, 15.0);
|
||||
for (op, count) in [(BoolOp::Cut, 9), (BoolOp::Union, 12), (BoolOp::Intersect, 6)] {
|
||||
let r = crate::polybool::planar_boolean(&a, &b, op).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.02);
|
||||
assert_eq!(h.faces.len(), count, "{op:?}\n{}", show(h));
|
||||
assert!(h.faces.iter().all(|f| f.origins.len() == 1));
|
||||
// Three faces of each meet three of the other's.
|
||||
assert_eq!(meets(h).len(), 6, "{op:?}\n{}", show(h));
|
||||
assert!(h.edges.iter().all(|e| e.origins.len() == 1), "{op:?}\n{}", show(h));
|
||||
}
|
||||
}
|
||||
|
||||
/// The nudged retry: a boolean with the tool moved a hair, composed with the move.
|
||||
#[test]
|
||||
fn nudged_tool_composes() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let b = crate::sphere(Vec3::new(10.0, 10.0, 10.0), 4.0).unwrap();
|
||||
let nb = crate::transform(&b, Vec3::new(1e-5, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).unwrap();
|
||||
let r = boolean(&a, &nb, BoolOp::Cut).unwrap().unwrap();
|
||||
let r = crate::ops::nudged(r, &nb, &crate::record::counts(&[&a, &b]));
|
||||
verified(&r, &[&a, &b], 0.02);
|
||||
}
|
||||
|
||||
/// A boolean with a triangle mesh: the mesh's faces are its triangles (and it has no edges).
|
||||
#[test]
|
||||
fn faceted_tool() {
|
||||
let a = boxed(0.0, 0.0, 0.0, 10.0, 10.0, 10.0);
|
||||
let tool = boxed(5.0, 5.0, 5.0, 15.0, 15.0, 15.0).tessellate(0.1).unwrap();
|
||||
let r = crate::faceted_boolean(&a, &tool, BoolOp::Cut, 0.01).unwrap().unwrap();
|
||||
let h = r.history().expect("the faceted boolean reports its history");
|
||||
h.check_counts((r.face_count(), r.edge_count()), &[(a.face_count(), a.edge_count()), (tool.triangles.len(), 0)]).unwrap();
|
||||
// The box's six faces (three notched) and the three tool faces lining the notch.
|
||||
let from_a: BTreeSet<_> = h.faces.iter().flat_map(|f| &f.origins).filter(|o| matches!(o, FaceOrigin::Face { operand: 0, .. })).collect();
|
||||
assert_eq!(from_a.len(), 6);
|
||||
assert_eq!(h.faces.iter().filter(|f| matches!(f.origins.first(), Some(FaceOrigin::Face { operand: 1, .. }))).count(), 3);
|
||||
}
|
||||
|
||||
/// A partial-depth cut of stepped prisms: walls over one segment through successive slabs are
|
||||
/// sewn as one face (coalesced), which comes from the operand walls under it in every slab.
|
||||
#[test]
|
||||
fn coalesced_stepped_walls() {
|
||||
use solvecraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
for curved in [true, false] {
|
||||
let (outer, inner) = if curved {
|
||||
(Loop2::circle(Vec2::ZERO, 10.0), Loop2::circle(Vec2::ZERO, 5.0))
|
||||
} else {
|
||||
let square = |h: f64| Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]);
|
||||
(square(10.0), square(5.0))
|
||||
};
|
||||
let stock = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
|
||||
let cutter = crate::extrude(&Plane::XY, &[Region2 { outer: inner, holes: vec![] }], 2.0, 22.0).unwrap().pop().unwrap();
|
||||
let r = boolean(&stock, &cutter, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&r, &[&stock, &cutter], 0.01);
|
||||
// The stock's walls run whole from 0 to 20, one face each, past the cutter's floor.
|
||||
let walls: Vec<usize> = (0..r.face_count()).filter(|i| r.face_was_coalesced(*i)).collect();
|
||||
assert_eq!(walls.len(), if curved { 2 } else { 4 }, "{}", show(h));
|
||||
for i in walls {
|
||||
assert!(matches!(h.faces[i].origins.as_slice(), [FaceOrigin::Face { operand: 0, .. }]), "{}", show(h));
|
||||
assert_eq!(h.faces[i].change, Change::Modified, "{}", show(h));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A step cut into a box: the stepped prism path sews the box's face 3 in pieces at the
|
||||
/// slab stations, and the joining of generated walls on their common supports makes it one face
|
||||
/// again; the joined body keeps the sewn body's history composed through the join. A certified
|
||||
/// box (whose planar regularization would join the same pieces) gets the same history.
|
||||
#[test]
|
||||
fn regularized_step_cut() {
|
||||
let (pa, pb) = (boxed(0.0, 0.0, 0.0, 30.0, 35.0, 7.5), boxed(0.0, 30.0, 2.5, 22.5, 50.0, 25.0));
|
||||
let (a, b) = (pa.clone().with_box_certificate(1).unwrap(), pb.clone().with_box_certificate(2).unwrap());
|
||||
let r = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap();
|
||||
let h = verified(&r, &[&a, &b], 0.01);
|
||||
let plain = boolean(&pa, &pb, BoolOp::Cut).unwrap().unwrap();
|
||||
let ph = verified(&plain, &[&pa, &pb], 0.01);
|
||||
let joined = from(ph, face(0, 3));
|
||||
assert_eq!(joined.len(), 1, "{}", show(ph));
|
||||
assert_eq!(faces_of(ph)[joined[0]], (Change::Modified, vec![face(0, 3)]));
|
||||
assert_eq!(faces_of(h), faces_of(ph), "{}", show(h));
|
||||
}
|
||||
|
||||
/// A ring joined under a floor, its bore on the floor's hole (coincident cylinders): the ring's
|
||||
/// bore is narrowed, the bodies joined, and the bore cut out again. The ring's bore is a circle
|
||||
/// built in two arcs, and the recut wall is split where the cutting cylinder's own construction
|
||||
/// splits it, so which arc each piece of it restores is not known: no history.
|
||||
#[test]
|
||||
fn bore_union_of_a_bore_in_two_arcs_has_no_history() {
|
||||
let hole = crate::cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 10.0, 30.0).unwrap();
|
||||
let floor = boolean(&boxed(0.0, 0.0, 0.0, 100.0, 80.0, 10.0), &hole, BoolOp::Cut).unwrap().unwrap();
|
||||
let centre = solvecraft_geom::Vec2::new(50.0, 40.0);
|
||||
let annulus = solvecraft_geom::Region2 {
|
||||
outer: solvecraft_geom::Loop2::circle(centre, 20.0),
|
||||
holes: vec![solvecraft_geom::Loop2::circle(centre, 10.0).reversed()],
|
||||
};
|
||||
let ring = crate::extrude(&solvecraft_geom::Plane::XY, &[annulus], -5.0, 2.0).unwrap().remove(0);
|
||||
let r = crate::coplanar::bore_union(&floor, &ring, &|_| true).expect("the bores are recut");
|
||||
assert!(r.history().is_none());
|
||||
}
|
||||
|
||||
/// Bodies clearly apart, joined as one body of several pieces (`union_apart`): every face and
|
||||
/// edge is its piece's own, kept.
|
||||
#[test]
|
||||
fn bodies_apart_joined_keep_their_faces() {
|
||||
let pieces = [boxed(0.0, 0.0, 0.0, 2.0, 2.0, 2.0), boxed(5.0, 0.0, 0.0, 7.0, 2.0, 2.0), boxed(10.0, 0.0, 0.0, 12.0, 2.0, 2.0)];
|
||||
let r = crate::union_apart(&pieces).unwrap().unwrap();
|
||||
let operands: Vec<&Body> = pieces.iter().collect();
|
||||
let h = verified(&r, &operands, 0.01);
|
||||
assert_eq!(h.operands, 3);
|
||||
for (k, piece) in pieces.iter().enumerate() {
|
||||
for f in 0..piece.face_count() {
|
||||
assert_eq!(from(h, face(k, f)).len(), 1, "{}", show(h));
|
||||
}
|
||||
}
|
||||
assert!(faces_of(h).iter().all(|(change, origins)| *change == Change::Kept && origins.len() == 1), "{}", show(h));
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Recording the exact history of a boolean's result, whichever path made it (see
|
||||
//! [`crate::ops::boolean`]): for each result face, the operand faces it is a region of; for each
|
||||
//! result edge, the operand edges it lies along or the operand faces whose meeting it is. The
|
||||
//! operands are `[a, b]`: the target, then the tool. Paths compose the histories of the steps
|
||||
//! they take ([`History::over`], [`History::substitute`]); a path that cannot leaves the result
|
||||
//! without history.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::Result;
|
||||
use crate::body::{Body, Solid, edge_order};
|
||||
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
|
||||
use crate::record;
|
||||
|
||||
/// A composition step's history, or none (logged) where the lineage can't be expressed.
|
||||
pub(crate) fn composed(h: Result<History>) -> Option<History> {
|
||||
h.map_err(|e| log::debug!("no history: {e}")).ok()
|
||||
}
|
||||
|
||||
/// `h`, the history of an operation over one operand, as over `operands` with that one at
|
||||
/// `operand`.
|
||||
pub(crate) fn placed(h: Option<History>, operand: usize, operands: usize) -> Option<History> {
|
||||
composed(h?.renumbered(&[Some(operand)], operands))
|
||||
}
|
||||
|
||||
/// The history of `body` as operand `operand` of `operands`: every face and edge kept.
|
||||
pub(crate) fn kept(body: &Body, operand: usize, operands: usize) -> Option<History> {
|
||||
placed(Some(History::identity(body)), operand, operands)
|
||||
}
|
||||
|
||||
/// The history of `result`, made from operands of which operand `k` was made by the operation
|
||||
/// `inner` describes, with `inner`'s operands in `k`'s place.
|
||||
pub(crate) fn through(result: &Body, k: usize, inner: Option<&History>) -> Option<History> {
|
||||
composed(result.history()?.clone().substituted(k, inner?))
|
||||
}
|
||||
|
||||
/// Operand `operand` (with `counts` giving every operand's faces and edges) as the result, with
|
||||
/// the history that says so.
|
||||
pub(crate) fn operand(body: &Body, operand: usize, counts: &[(usize, usize)]) -> Body {
|
||||
body.clone().with_checked_history(kept(body, operand, counts.len()), counts)
|
||||
}
|
||||
|
||||
/// The histories of `parts`, each made of shells of `whole` (its lumps), over `[whole]`: their
|
||||
/// faces and edges are the whole's own (by identity).
|
||||
pub(crate) fn parts_of(parts: &[Body], whole: &Body) -> Vec<Option<History>> {
|
||||
let (faces, edges) = (record::face_index(&whole.solid), record::edge_origins(&whole.solid, 0));
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
let faces = part.solid.face_iter().map(|f| Some(record::from_face(0, *faces.get(&f.id())?, Change::Kept))).collect::<Option<Vec<_>>>()?;
|
||||
Some(History { operands: 1, faces, edges: record::edge_provs(part, &edges)? })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A body with its history over the operation's operands (`None`: not known).
|
||||
pub(crate) type Traced = (Body, Option<History>);
|
||||
|
||||
/// The history over the operation's operands of `result`, made from `x` and `y` (in that order)
|
||||
/// whose histories over them are given.
|
||||
pub(crate) fn of_pair(result: &Body, x: &Option<History>, y: &Option<History>) -> Option<History> {
|
||||
composed(result.history()?.clone().over(&[x.as_ref()?, y.as_ref()?]))
|
||||
}
|
||||
|
||||
/// Bodies apart from each other, each with its history over the operation's operands (with
|
||||
/// `counts` faces and edges), as one body of several pieces whose history is theirs side by side.
|
||||
pub(crate) fn join(pieces: Vec<Traced>, counts: &[(usize, usize)]) -> Result<Option<Body>> {
|
||||
if pieces.len() < 2 {
|
||||
return Ok(pieces.into_iter().next().map(|(b, h)| b.with_checked_history(h, counts)));
|
||||
}
|
||||
// The pieces' shells in order: their faces in order, their edges themselves.
|
||||
let mut faces = Some(Vec::new());
|
||||
let mut edges = HashMap::new();
|
||||
for (p, h) in &pieces {
|
||||
let (Some(h), Some(all)) = (h, faces.as_mut()) else {
|
||||
faces = None;
|
||||
break;
|
||||
};
|
||||
all.extend(h.faces.iter().cloned());
|
||||
edges.extend(record::edges_by_identity(p, h));
|
||||
}
|
||||
if let Some(faces) = faces.as_mut() {
|
||||
record::mark_shared_split(faces);
|
||||
}
|
||||
let joined = crate::ops::join_pieces(pieces.into_iter().map(|(p, _)| p).collect())?;
|
||||
Ok(joined.map(|body| record::traced(body, counts, faces, Some(&edges))))
|
||||
}
|
||||
|
||||
/// A truck boolean's history in our terms, in the order of the solid it made: each face a
|
||||
/// region of one operand face (Kept when that very face), each edge's origins.
|
||||
pub(crate) type Shapeops = (Vec<FaceProv>, Vec<Vec<EdgeOrigin>>);
|
||||
|
||||
/// A truck boolean's history in our terms ([`Shapeops`]). `tool` is the
|
||||
/// tool as passed (after a cut turned it inside out, which keeps its faces' order but walks each
|
||||
/// face's edges the other way, so its edge order changes) and `tool_edges` its edges by identity
|
||||
/// in its own order (before turning).
|
||||
pub(crate) fn shapeops(h: &truck_shapeops::History, tool: &Solid, tool_edges: Option<&HashMap<mt::EdgeID, Vec<EdgeOrigin>>>) -> Option<Shapeops> {
|
||||
let face = |f: truck_shapeops::FaceIndex| FaceOrigin::Face { operand: f.operand, face: f.face };
|
||||
let faces =
|
||||
h.faces.iter().map(|o| record::from_face(o.source.operand, o.source.face, if o.kept { Change::Kept } else { Change::Modified })).collect();
|
||||
let passed: Vec<mt::EdgeID> = edge_order(tool.face_iter()).0.iter().map(|e| e.id()).collect();
|
||||
let edges = h
|
||||
.edges
|
||||
.iter()
|
||||
.map(|o| match *o {
|
||||
truck_shapeops::EdgeOrigin::Edge { operand: 1, edge } if tool_edges.is_some() => {
|
||||
tool_edges.and_then(|t| t.get(passed.get(edge)?)).cloned()
|
||||
}
|
||||
truck_shapeops::EdgeOrigin::Edge { operand, edge } => Some(vec![EdgeOrigin::Edge { operand, edge }]),
|
||||
truck_shapeops::EdgeOrigin::Meet { a, b } => Some(vec![EdgeOrigin::meet(face(a), face(b))]),
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some((faces, edges))
|
||||
}
|
||||
|
||||
/// The body made of `solid` healed (keeping faces apart along `keep`), with the history whose
|
||||
/// faces and edges (in `solid`'s orders) are given before healing, carried through it.
|
||||
/// Operands with `counts` faces and edges.
|
||||
pub(crate) fn heal_build(
|
||||
solid: Solid,
|
||||
size: f64,
|
||||
keep: &[Vec<Vec3>],
|
||||
faces: Option<Vec<FaceProv>>,
|
||||
edges: Option<Vec<Vec<EdgeOrigin>>>,
|
||||
counts: &[(usize, usize)],
|
||||
) -> Result<Body> {
|
||||
let (healed, map) = crate::heal::heal_traced(solid, size, keep);
|
||||
let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e));
|
||||
let (faces, edges) = carried.unzip();
|
||||
record::build(healed, counts, faces, edges.as_ref())
|
||||
}
|
||||
|
||||
/// The body truck's result `s` makes, healed (see [`heal_build`]), with its history `h`.
|
||||
pub(crate) fn truck_result(s: Solid, h: Option<Shapeops>, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
|
||||
let (faces, edges) = h.unzip();
|
||||
heal_build(s, size, keep, faces, edges, counts)
|
||||
}
|
||||
|
||||
/// `body` healed (see [`heal_build`]), its history carried through: `solid` is `body`'s solid
|
||||
/// or a copy of it (`Body::deep_copy`, which keeps its order).
|
||||
pub(crate) fn heal_body(body: &Body, solid: Solid, size: f64, keep: &[Vec<Vec3>], counts: &[(usize, usize)]) -> Result<Body> {
|
||||
let (faces, edges) = body.history().map(|h| (h.faces.clone(), h.edges.iter().map(|e| e.origins.clone()).collect())).unzip();
|
||||
heal_build(solid, size, keep, faces, edges, counts)
|
||||
}
|
||||
|
||||
/// `body` healed as the local modifiers take it (coplanar neighbours as one face, as after a
|
||||
/// join; faces split on purpose kept apart), with its history over `[body]`.
|
||||
pub(crate) fn healed(body: &Body) -> Result<Body> {
|
||||
let counts = record::counts(&[body]);
|
||||
heal_body(&operand(body, 0, &counts), body.deep_copy(), body.size(), body.split_keep(), &counts)
|
||||
}
|
||||
|
||||
/// `body` copied as it is, unhealed (`Body::deep_copy`, which keeps its face and edge order),
|
||||
/// with its history over `[body]`: every face and edge kept.
|
||||
pub(crate) fn copied(body: &Body) -> Result<Body> {
|
||||
let copy = Body::new(body.deep_copy())?;
|
||||
Ok(operand(©, 0, &record::counts(&[body])))
|
||||
}
|
||||
|
||||
/// The faces and edges of `a`, then of the tool `b` turned inside out as a void (`inner`, a
|
||||
/// copy of `b`, with `inner_edges` its edges by identity before turning): the history of
|
||||
/// `a` with `b`'s inside taken out where `b` lies wholly within it.
|
||||
pub(crate) fn void(
|
||||
a: &Body,
|
||||
inner_edges: HashMap<mt::EdgeID, Vec<EdgeOrigin>>,
|
||||
b_faces: usize,
|
||||
) -> (Vec<FaceProv>, HashMap<mt::EdgeID, Vec<EdgeOrigin>>) {
|
||||
let faces = (0..a.face_count())
|
||||
.map(|i| record::from_face(0, i, Change::Kept))
|
||||
.chain((0..b_faces).map(|i| record::from_face(1, i, Change::Kept)))
|
||||
.collect();
|
||||
let mut edges = record::edge_origins(&a.solid, 0);
|
||||
edges.extend(inner_edges);
|
||||
(faces, edges)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "boolean_history_tests.rs"]
|
||||
pub(crate) mod tests;
|
||||
+285
-183
@@ -5,6 +5,9 @@ use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::history::FaceProv;
|
||||
use crate::numbered::{self, NumberedLoop, NumberedRegion};
|
||||
use crate::record::{self, Side};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
const MAX_SEGS: usize = 20_000;
|
||||
@@ -18,7 +21,9 @@ pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
wire_sources(plane, lp).map(|(wire, _)| wire)
|
||||
}
|
||||
|
||||
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2)>)> {
|
||||
/// A closed wire in 3D from a sketch loop, with each edge's segment and that segment's
|
||||
/// position in the loop (a big arc makes two edges).
|
||||
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
|
||||
let n = lp.segs.len();
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid(format!("loop with {n} segments")));
|
||||
@@ -41,7 +46,7 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
|
||||
if sweep.abs() > std::f64::consts::PI * 1.5 {
|
||||
let vm = builder::vertex(p3(plane.to_world(s.mid())));
|
||||
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
|
||||
sources.push((first.clone(), *s));
|
||||
sources.push((first.clone(), *s, i));
|
||||
edges.push(first);
|
||||
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
|
||||
} else {
|
||||
@@ -52,19 +57,71 @@ fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, S
|
||||
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
|
||||
}
|
||||
};
|
||||
sources.push((e.clone(), *s));
|
||||
sources.push((e.clone(), *s, i));
|
||||
edges.push(e);
|
||||
}
|
||||
Ok((edges.into(), sources))
|
||||
}
|
||||
|
||||
/// An edge of a profile wire: the segment it was built from and the profile segments under it.
|
||||
pub(crate) struct ProfileEdge {
|
||||
pub edge: mt::Edge,
|
||||
pub segment: Seg2,
|
||||
pub numbers: Vec<usize>,
|
||||
}
|
||||
|
||||
/// A wire built from a profile loop, with its edges in wire order.
|
||||
pub(crate) struct ProfileWire {
|
||||
pub wire: mt::Wire,
|
||||
pub edges: Vec<ProfileEdge>,
|
||||
}
|
||||
|
||||
impl ProfileWire {
|
||||
/// The profile segments under each edge, in wire order.
|
||||
fn numbers(&self) -> Vec<Vec<usize>> {
|
||||
self.edges.iter().map(|e| e.numbers.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The wires of a region oriented as the builders build it ([`NumberedRegion::oriented`]), each
|
||||
/// with what its edges were built from.
|
||||
fn region_wires(plane: &Plane, r: &NumberedRegion) -> Result<Vec<ProfileWire>> {
|
||||
r.loops()
|
||||
.map(|lp| {
|
||||
let (wire, s) = wire_sources(plane, &lp.lp)?;
|
||||
let edges = s.into_iter().map(|(edge, segment, i)| ProfileEdge { edge, segment, numbers: lp.numbers(i).to_vec() }).collect();
|
||||
Ok(ProfileWire { wire, edges })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The wires alone.
|
||||
fn wires_of(profile: &[ProfileWire]) -> Vec<mt::Wire> {
|
||||
profile.iter().map(|p| p.wire.clone()).collect()
|
||||
}
|
||||
|
||||
/// The profile edges as sweep seeds: each edge with the profile segments under it.
|
||||
fn seeds(profile: &[ProfileWire]) -> Vec<(mt::EdgeID, Vec<usize>)> {
|
||||
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e.numbers.clone())).collect()
|
||||
}
|
||||
|
||||
/// A region numbered on its own, as given.
|
||||
fn numbered_region(r: &Region2) -> Result<NumberedRegion> {
|
||||
numbered::number_regions(std::slice::from_ref(r)).pop().ok_or_else(|| KernelError::Invalid("profile".into()))
|
||||
}
|
||||
|
||||
/// A region numbered on its own and oriented as the builders build it.
|
||||
fn oriented(r: &Region2) -> Result<NumberedRegion> {
|
||||
numbered_region(r).map(|n| n.oriented())
|
||||
}
|
||||
|
||||
/// A planar face from a region (outer loop with holes).
|
||||
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
|
||||
let mut wires = vec![wire(plane, &r.outer.ccw())?];
|
||||
for h in &r.holes {
|
||||
wires.push(wire(plane, &h.ccw().reversed())?);
|
||||
}
|
||||
guard("face", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
attach_profile(&wires_of(®ion_wires(plane, &oriented(r)?)?))
|
||||
}
|
||||
|
||||
fn attach_profile(wires: &[mt::Wire]) -> Result<mt::Face> {
|
||||
guard("face", || builder::try_attach_plane(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
}
|
||||
|
||||
fn region_ok(r: &Region2) -> Result<()> {
|
||||
@@ -112,82 +169,81 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
for r in regions {
|
||||
region_ok(r)?;
|
||||
let base = plane.offset(lo);
|
||||
let (outer, mut sources) = wire_sources(&base, &r.outer.ccw())?;
|
||||
let mut wires = vec![outer];
|
||||
for hole in &r.holes {
|
||||
let (wire, mut hole_sources) = wire_sources(&base, &hole.ccw().reversed())?;
|
||||
wires.push(wire);
|
||||
sources.append(&mut hole_sources);
|
||||
}
|
||||
let profile = region_wires(&base, &oriented(r)?)?;
|
||||
let wires = wires_of(&profile);
|
||||
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
|
||||
let upper = guard("extrude cap identities", || {
|
||||
let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect();
|
||||
let body = Body::new(solid)?;
|
||||
// tsweep retains each actual input boundary edge on its generated side: one pass over
|
||||
// the faces matches topology identity (never face order, surface samples or
|
||||
// centroids) for both the face sources and the history. The profile face is the start
|
||||
// cap, the one face holding no profile edge the end cap.
|
||||
let (sources, history) = guard("extrude source identities", || {
|
||||
let by_edge: std::collections::HashMap<mt::EdgeID, &ProfileEdge> =
|
||||
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e)).collect();
|
||||
let mut faces: Vec<(mt::FaceID, Vec<&ProfileEdge>)> = Vec::new();
|
||||
let mut visits = 0usize;
|
||||
let mut candidate = None;
|
||||
for (index, generated) in solid.face_iter().enumerate() {
|
||||
if index >= 50_000 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut side = false;
|
||||
for generated in body.solid.face_iter() {
|
||||
let mut matching = Vec::new();
|
||||
for edge in generated.absolute_boundaries().iter().flatten() {
|
||||
visits += 1;
|
||||
if visits > 200_000 {
|
||||
return Ok(None);
|
||||
if let Some(e) = by_edge.get(&edge.id()) {
|
||||
matching.push(*e);
|
||||
}
|
||||
side |= inputs.contains(&edge.id());
|
||||
}
|
||||
if generated.id() != f.id() && !side {
|
||||
if candidate.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
candidate = Some(generated.id());
|
||||
}
|
||||
faces.push((generated.id(), matching));
|
||||
}
|
||||
Ok(candidate)
|
||||
})?;
|
||||
let body = Body::new(solid)?;
|
||||
// tsweep retains each actual input boundary edge on its generated side.
|
||||
// Match topology identity, never face order, surface samples or centroids.
|
||||
let provenance = guard("extrude source identities", || {
|
||||
let source_edges: std::collections::HashMap<_, _> = sources.iter().map(|(edge, segment)| (edge.id(), *segment)).collect();
|
||||
let mut result = Vec::new();
|
||||
for generated in body.solid.face_iter() {
|
||||
let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect();
|
||||
let source =
|
||||
if generated.id() == f.id() {
|
||||
upper.and_then(|_| {
|
||||
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096).then(|| {
|
||||
crate::FaceSource::ExtrudeCap {
|
||||
high: false,
|
||||
region: r.clone(),
|
||||
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
|
||||
}
|
||||
})
|
||||
let mut ends = faces.iter().filter(|(id, matching)| *id != f.id() && matching.is_empty()).map(|(id, _)| *id);
|
||||
let end = match (ends.next(), ends.next()) {
|
||||
(Some(id), None) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
let upper = end.filter(|_| faces.len() <= 50_000 && visits <= 200_000);
|
||||
let small = r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096;
|
||||
let mut sources = Vec::with_capacity(faces.len());
|
||||
let mut history = Some(Vec::with_capacity(faces.len()));
|
||||
for (id, matching) in &faces {
|
||||
let (source, prov) = if *id == f.id() {
|
||||
let source = upper.and_then(|_| {
|
||||
small.then(|| crate::FaceSource::ExtrudeCap {
|
||||
high: false,
|
||||
region: r.clone(),
|
||||
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
|
||||
})
|
||||
} else if Some(generated.id()) == upper {
|
||||
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096)
|
||||
.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) })
|
||||
} else if let [segment] = matching.as_slice() {
|
||||
let basis = match **segment {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
d.normalized().and_then(|direction| {
|
||||
let foot = a - direction * a.dot(direction);
|
||||
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis, coalesced: false })
|
||||
} else {
|
||||
None
|
||||
});
|
||||
(source, Some(record::cap(false)))
|
||||
} else if Some(*id) == upper {
|
||||
(small.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }), Some(record::cap(true)))
|
||||
} else if let [side] = matching.as_slice() {
|
||||
let basis = match side.segment {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
d.normalized().and_then(|direction| {
|
||||
let foot = a - direction * a.dot(direction);
|
||||
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
result.push(source);
|
||||
(
|
||||
Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }),
|
||||
Some(record::profile(&side.numbers)),
|
||||
)
|
||||
} else if Some(*id) == end {
|
||||
(None, Some(record::cap(true)))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
sources.push(source);
|
||||
history = history.zip(prov).map(|(mut h, p)| {
|
||||
h.push(p);
|
||||
h
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
Ok((sources, history))
|
||||
})?;
|
||||
out.push(body.with_face_sources(Some(std::sync::Arc::new(provenance))));
|
||||
let body = body.with_face_sources(Some(std::sync::Arc::new(sources)));
|
||||
out.push(record::attach(body, &[], history, Default::default())?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -242,19 +298,17 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
let q = axis_origin + d * along;
|
||||
if p.dist(q) < 1e-7 { q } else { p }
|
||||
};
|
||||
let snap_loop = |l: &Loop2| Loop2 {
|
||||
segs: l
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
|
||||
arc => arc,
|
||||
})
|
||||
.collect(),
|
||||
let snap_loop = |l: &NumberedLoop| {
|
||||
l.map_segs(|s| match *s {
|
||||
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
|
||||
arc => arc,
|
||||
})
|
||||
};
|
||||
// Full circles start at an odd angle: their seam points then don't lie in the planes
|
||||
// through the profile's centre that other bodies tend to cut along.
|
||||
let reseam = |l: Loop2| -> Loop2 {
|
||||
// through the profile's centre that other bodies tend to cut along. Each new arc comes
|
||||
// from every given arc it overlaps.
|
||||
let reseam = |nl: NumberedLoop| -> NumberedLoop {
|
||||
let l = &nl.lp;
|
||||
let c = match l.segs.first() {
|
||||
Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)),
|
||||
_ => None,
|
||||
@@ -282,30 +336,41 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
{
|
||||
let ccw = l.signed_area() > 0.0;
|
||||
let n = Loop2::circle_from(center, radius, 0.4142);
|
||||
if ccw { n } else { n.reversed() }
|
||||
nl.resplit_circle(if ccw { n } else { n.reversed() })
|
||||
}
|
||||
_ => l,
|
||||
_ => nl,
|
||||
}
|
||||
};
|
||||
let r = &Region2 { outer: reseam(snap_loop(&r.outer)), holes: r.holes.iter().map(|h| reseam(snap_loop(h))).collect() };
|
||||
// A half disc on the axis revolved fully is a sphere: build it without poles.
|
||||
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l)));
|
||||
let r = &given.region();
|
||||
// A half disc on the axis revolved fully is a sphere: build it without poles. Every
|
||||
// patch is swept from the half disc's arcs.
|
||||
if angle.abs() >= std::f64::consts::TAU - 1e-9
|
||||
&& r.holes.is_empty()
|
||||
&& let Some((c, rad)) = half_disc(&r.outer, axis_origin, d)
|
||||
{
|
||||
out.push(sphere_patches(plane.to_world(c), rad)?);
|
||||
let arcs: Vec<usize> = r
|
||||
.outer
|
||||
.segs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| matches!(s, Seg2::Arc { .. }))
|
||||
.flat_map(|(i, _)| given.outer.numbers(i).to_vec())
|
||||
.collect();
|
||||
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
|
||||
continue;
|
||||
}
|
||||
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
|
||||
// as a cone-like shell (no zero-area faces, which booleans can't handle).
|
||||
if angle.abs() >= std::f64::consts::TAU - 1e-9
|
||||
&& r.holes.is_empty()
|
||||
&& let Some(b) = revolve_touching_axis(plane, &r.outer, axis_origin, d)?
|
||||
&& let Some(b) = revolve_touching_axis(plane, &given.outer, axis_origin, d)?
|
||||
{
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
let f = face(plane, r)?;
|
||||
let profile = region_wires(plane, &given.oriented())?;
|
||||
let f = attach_profile(&wires_of(&profile))?;
|
||||
// Partial revolves sweep toward the side the sketch normal points to (as features
|
||||
// extrude toward it).
|
||||
let c = plane.to_world(r.centroid());
|
||||
@@ -314,13 +379,15 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
let angle = if toward < 0.0 { -angle } else { angle };
|
||||
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
|
||||
if r.holes.is_empty()
|
||||
&& let Some(b) = revolve_partial_on_axis(plane, &r.outer, axis_origin, d, angle)?
|
||||
&& let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)?
|
||||
{
|
||||
out.push(b);
|
||||
continue;
|
||||
}
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
let closed = angle.abs() >= std::f64::consts::TAU;
|
||||
let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?;
|
||||
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -467,16 +534,22 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
|
||||
region_ok(region)?;
|
||||
let d = length * taper.tan();
|
||||
let top_plane = plane.offset(length * dir_sign.signum());
|
||||
let loops0: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
|
||||
let loops1: Vec<Loop2> = loops0.iter().map(|l| offset_loop(l, d)).collect::<Result<_>>()?;
|
||||
let loops0 = oriented(region)?;
|
||||
let loops1: Vec<Loop2> = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::<Result<_>>()?;
|
||||
guard("tapered extrude", || {
|
||||
let w0: Vec<mt::Wire> = loops0.iter().map(|l| wire(plane, l)).collect::<Result<_>>()?;
|
||||
let p0 = region_wires(plane, &loops0)?;
|
||||
let w0 = wires_of(&p0);
|
||||
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for (a, b) in w0.iter().zip(&w1) {
|
||||
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
let mut sides: Vec<Side> = Vec::new();
|
||||
for (a, b) in p0.iter().zip(&w1) {
|
||||
let shell = builder::try_wire_homotopy(&a.wire, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
|
||||
// The offset loop keeps the loop's segments, edge for edge.
|
||||
let numbers = a.numbers();
|
||||
sides.extend(record::ruled(&shell, &a.wire, b, &numbers, &numbers));
|
||||
}
|
||||
let (faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
|
||||
// The sides, then the bottom and top as pushed below.
|
||||
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(false)), Some(record::cap(true))]).collect();
|
||||
let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>())
|
||||
.map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?;
|
||||
let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?;
|
||||
@@ -489,7 +562,7 @@ pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f
|
||||
all.push(t);
|
||||
let shell: mt::Shell = all.into();
|
||||
match Solid::try_new(vec![shell]) {
|
||||
Ok(s) => return Body::new(s),
|
||||
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -518,8 +591,9 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
|
||||
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
|
||||
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
|
||||
/// loop isn't like that.
|
||||
fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||||
fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||||
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
|
||||
let lp = &numbered.lp;
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
let axis_segs: Vec<usize> =
|
||||
@@ -574,8 +648,9 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
|
||||
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
|
||||
}
|
||||
}
|
||||
// Side faces: each edge's swept surface, oriented to agree with its boundary.
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
// Side faces: each edge's swept surface, oriented to agree with its boundary, from the
|
||||
// profile segment the edge was built from.
|
||||
let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new();
|
||||
for (i, sg) in chain.iter().enumerate() {
|
||||
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
|
||||
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
|
||||
@@ -615,7 +690,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
|
||||
}
|
||||
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
|
||||
};
|
||||
faces.push(f);
|
||||
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
|
||||
}
|
||||
// Caps: the profile at the start and at the end, sharing the axis edge.
|
||||
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
|
||||
@@ -624,6 +699,8 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
|
||||
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
|
||||
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
|
||||
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
|
||||
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
|
||||
history.extend([record::cap(false), record::cap(true)]);
|
||||
let mut last_err = String::new();
|
||||
for flip in 0..4 {
|
||||
let mut all = faces.clone();
|
||||
@@ -632,7 +709,7 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
|
||||
match Solid::try_new(vec![all.into()]) {
|
||||
Ok(solid) => {
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
return Body::new(solid).map(Some);
|
||||
return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some);
|
||||
}
|
||||
Err(er) => last_err = er.to_string(),
|
||||
}
|
||||
@@ -641,14 +718,17 @@ fn revolve_partial_on_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2
|
||||
})
|
||||
}
|
||||
|
||||
fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
|
||||
fn revolve_touching_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
|
||||
let lp = &numbered.lp;
|
||||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||||
let n = lp.segs.len();
|
||||
let axis_segs: Vec<usize> =
|
||||
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
|
||||
let [k] = axis_segs[..] else { return Ok(None) };
|
||||
// The open chain after the axis segment, back around to it.
|
||||
// The open chain after the axis segment, back around to it, with the profile segments
|
||||
// under each piece.
|
||||
let mut chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
|
||||
let mut chain_src: Vec<&[usize]> = (1..n).map(|j| numbered.numbers((k + j) % n)).collect();
|
||||
// A single arc (a sphere) is split off-centre: a seam on the equator would lie in the very
|
||||
// planes other bodies are often cut with.
|
||||
if let [Seg2::Arc { center, radius, start, sweep }] = chain[..] {
|
||||
@@ -657,6 +737,7 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
Seg2::Arc { center, radius, start, sweep: sweep * f },
|
||||
Seg2::Arc { center, radius, start: start + sweep * f, sweep: sweep * (1.0 - f) },
|
||||
];
|
||||
chain_src = vec![numbered.numbers((k + 1) % n); 2];
|
||||
}
|
||||
if chain.is_empty() || chain.iter().skip(1).take(chain.len().saturating_sub(2)).any(|s| on_axis(s.start()) && on_axis(s.end())) {
|
||||
return Ok(None);
|
||||
@@ -669,11 +750,18 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
// Radial lines at the ends become planar caps (no degenerate disc centres).
|
||||
let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||||
let mut mid: &[Seg2] = &chain;
|
||||
let mut mid_src: &[&[usize]] = &chain_src;
|
||||
// The segments of the radial lines that become caps.
|
||||
let (mut start_cap, mut end_cap) = (None, None);
|
||||
if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) {
|
||||
mid = mid.get(1..).unwrap_or(&[]);
|
||||
start_cap = mid_src.first().copied();
|
||||
mid_src = mid_src.get(1..).unwrap_or(&[]);
|
||||
}
|
||||
if mid.last().is_some_and(|l| radial(l) && on_axis(l.end())) {
|
||||
mid = mid.get(..mid.len().saturating_sub(1)).unwrap_or(&[]);
|
||||
end_cap = mid_src.last().copied();
|
||||
mid_src = mid_src.get(..mid_src.len().saturating_sub(1)).unwrap_or(&[]);
|
||||
}
|
||||
if mid.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -698,6 +786,8 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
},
|
||||
});
|
||||
}
|
||||
// Each edge with the profile segments under it, before the wire may be turned round.
|
||||
let seeds: Vec<(mt::EdgeID, Vec<usize>)> = edges.iter().map(|e| e.id()).zip(mid_src.iter().map(|s| s.to_vec())).collect();
|
||||
let mut w: mt::Wire = edges.into();
|
||||
let axis = plane.dir_to_world(d);
|
||||
let mut shell = if apex_start || apex_end {
|
||||
@@ -710,7 +800,29 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU))
|
||||
};
|
||||
let sides = shell.len();
|
||||
// History. truck sweeps each wire edge round into a column of faces (triangles at an
|
||||
// apex, which stays put); each cap is the disc a trimmed radial line sweeps, found by
|
||||
// the profile vertex on its rim.
|
||||
let fixed: Vec<mt::VertexID> =
|
||||
[(apex_start, verts.first()), (apex_end, verts.last())].into_iter().filter_map(|(apex, v)| v.filter(|_| apex).map(|v| v.id())).collect();
|
||||
let mut history: Option<Vec<FaceProv>> = (seeds.len() == mid.len())
|
||||
.then(|| record::columns(shell.face_iter(), &seeds, None, record::Column::ToEnd, &fixed))
|
||||
.flatten()
|
||||
.and_then(|sides| sides.into_iter().collect());
|
||||
let (first_vertex, last_vertex) = (verts.first().map(|v| v.id()), verts.last().map(|v| v.id()));
|
||||
for b in shell.extract_boundaries() {
|
||||
let at = |v: Option<mt::VertexID>| v.is_some_and(|v| b.vertex_iter().any(|x| x.id() == v));
|
||||
let src = if at(first_vertex) {
|
||||
start_cap
|
||||
} else if at(last_vertex) {
|
||||
end_cap
|
||||
} else {
|
||||
None
|
||||
};
|
||||
history = history.zip(src).map(|(mut h, numbers)| {
|
||||
h.push(record::profile(numbers));
|
||||
h
|
||||
});
|
||||
let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?;
|
||||
shell.push(cap);
|
||||
}
|
||||
@@ -724,12 +836,16 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2)
|
||||
});
|
||||
let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?;
|
||||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||||
Body::new(solid).map(Some)
|
||||
record::attach(Body::new(solid)?, &[], history, Default::default()).map(Some)
|
||||
})
|
||||
}
|
||||
|
||||
/// Close a ruled shell (side faces between matching wires) with planar caps into a body.
|
||||
pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result<Body> {
|
||||
/// Close a ruled shell (side faces between matching wires, with their provenance as built)
|
||||
/// with planar caps into a body, whose history is the sides' and the caps': the profile at
|
||||
/// `w0` is the start cap and at `w1` the end cap (the other way round when `from_end`).
|
||||
pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire], from_end: bool) -> Result<Body> {
|
||||
let (mut faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
|
||||
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(from_end)), Some(record::cap(!from_end))]).collect();
|
||||
let bottom =
|
||||
builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
|
||||
let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
|
||||
@@ -741,7 +857,7 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
|
||||
all.push(t);
|
||||
let shell: mt::Shell = all.into();
|
||||
match Solid::try_new(vec![shell]) {
|
||||
Ok(s) => return Body::new(s),
|
||||
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
faces = keep;
|
||||
@@ -749,42 +865,6 @@ pub(crate) fn cap_and_close(mut faces: Vec<mt::Face>, w0: &[mt::Wire], w1: &[mt:
|
||||
Err(KernelError::Failed(format!("closing the shell: {last}")))
|
||||
}
|
||||
|
||||
/// Re-split a loop into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
|
||||
/// other loops split their longest segments in half.
|
||||
fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 {
|
||||
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
|
||||
&& lp.segs.windows(2).all(|w| match (w[0], w[1]) {
|
||||
(Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => c1.dist(c2) < 1e-9 && (r1 - r2).abs() < 1e-9,
|
||||
_ => false,
|
||||
});
|
||||
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
|
||||
let mut a: Vec<f64> = if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
|
||||
// Keep the given order but make them increase.
|
||||
for i in 1..a.len() {
|
||||
while a[i] <= a[i - 1] {
|
||||
a[i] += std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
let segs = (0..n)
|
||||
.map(|i| {
|
||||
let s = a[i];
|
||||
let e = if i + 1 < n { a[i + 1] } else { a[0] + std::f64::consts::TAU };
|
||||
Seg2::Arc { center, radius, start: s, sweep: e - s }
|
||||
})
|
||||
.collect();
|
||||
return Loop2 { segs };
|
||||
}
|
||||
let mut segs = lp.segs.clone();
|
||||
while segs.len() < n {
|
||||
let Some((k, _)) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break };
|
||||
let s = segs.remove(k);
|
||||
let (p, q) = s.split_half();
|
||||
segs.insert(k, q);
|
||||
segs.insert(k, p);
|
||||
}
|
||||
Loop2 { segs }
|
||||
}
|
||||
|
||||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||||
/// same sections in reverse order give the same body.
|
||||
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
@@ -804,58 +884,67 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||||
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
|
||||
let step = centre(last) - centre(first);
|
||||
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
|
||||
let ordered: Vec<&(Plane, Loop2)> = if big < 0.0 { sections.iter().rev().collect() } else { sections.iter().collect() };
|
||||
let reversed = big < 0.0;
|
||||
// The sections' segments are numbered in the order given.
|
||||
let mut ordered: Vec<(Plane, NumberedLoop)> =
|
||||
sections.iter().map(|(p, _)| *p).zip(numbered::number_loops(sections.iter().map(|(_, l)| l))).collect();
|
||||
if reversed {
|
||||
ordered.reverse();
|
||||
}
|
||||
// Orient every section counter-clockwise about the first section's normal (`Body::new`
|
||||
// turns the solid outward), then match segment counts and starting points to the section
|
||||
// with the most segments.
|
||||
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
|
||||
let oriented: Vec<(Plane, Loop2)> = ordered
|
||||
let oriented: Vec<(Plane, NumberedLoop)> = ordered
|
||||
.iter()
|
||||
.map(|(p, l)| {
|
||||
let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() };
|
||||
(*p, l)
|
||||
let ccw = p.normal().dot(n0) >= 0.0;
|
||||
(*p, l.oriented(!ccw))
|
||||
})
|
||||
.collect();
|
||||
let lead = oriented.iter().max_by_key(|(_, l)| l.segs.len()).cloned().ok_or_else(|| KernelError::Invalid("no sections".into()))?;
|
||||
let lead_c = lead.0.to_world(lead.1.centroid());
|
||||
let lead_dirs: Vec<Vec3> = lead.1.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
|
||||
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
|
||||
let lead_c = lead.0.to_world(lead.1.lp.centroid());
|
||||
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
// Per section, the profile segments under each of its wire's edges.
|
||||
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
for (p, l) in &oriented {
|
||||
let c = l.centroid();
|
||||
let c = l.lp.centroid();
|
||||
// Break angles: the lead section's vertex directions seen in this plane.
|
||||
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
|
||||
let mut r = resample(l, n, &angles);
|
||||
let mut r = l.resample(n, &angles);
|
||||
// Start at the vertex closest in direction to the lead's first vertex.
|
||||
if let Some(d0) = lead_dirs.first() {
|
||||
let target = Vec2::new(d0.dot(p.x), d0.dot(p.y)).angle();
|
||||
let best = (0..r.segs.len()).min_by(|a, b| {
|
||||
let ang = |k: usize| {
|
||||
let v = r.segs[k].start() - c;
|
||||
let d = (v.angle() - target).rem_euclid(std::f64::consts::TAU);
|
||||
d.min(std::f64::consts::TAU - d)
|
||||
};
|
||||
ang(*a).total_cmp(&ang(*b))
|
||||
});
|
||||
let ang = |s: &Seg2| {
|
||||
let d = ((s.start() - c).angle() - target).rem_euclid(std::f64::consts::TAU);
|
||||
d.min(std::f64::consts::TAU - d)
|
||||
};
|
||||
let best = r.lp.segs.iter().enumerate().min_by(|a, b| ang(a.1).total_cmp(&ang(b.1))).map(|(k, _)| k);
|
||||
if let Some(k) = best {
|
||||
r.segs.rotate_left(k);
|
||||
r.rotate_left(k);
|
||||
}
|
||||
}
|
||||
wires.push(uniform_wire(p, &r)?);
|
||||
wires.push(uniform_wire(p, &r.lp)?);
|
||||
edge_sources.push(r.from);
|
||||
}
|
||||
// The wires run from the first section given, or (turned round) the last.
|
||||
// Three sections or more: one smooth surface through them all for each segment.
|
||||
if wires.len() >= 3
|
||||
&& let Ok(b) = guard("loft", || smooth_loft(&wires))
|
||||
&& let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed))
|
||||
{
|
||||
return Ok(b);
|
||||
}
|
||||
guard("loft", || {
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for w in wires.windows(2) {
|
||||
let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
// Each ruled face lies between two sections: it comes from the section edges it spans.
|
||||
let mut sides: Vec<Side> = Vec::new();
|
||||
for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) {
|
||||
let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue };
|
||||
let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
|
||||
sides.extend(record::ruled(&shell, a, b, na, nb));
|
||||
}
|
||||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) };
|
||||
cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last))
|
||||
cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -873,8 +962,10 @@ fn across(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
|
||||
|
||||
/// A loft whose side faces are B-spline surfaces through every section (smooth across the
|
||||
/// middle sections, which leave no edges). Every section wire comes from `uniform_wire`, so the
|
||||
/// k-th edges of all sections share one knot vector and their control points pair up.
|
||||
fn smooth_loft(wires: &[mt::Wire]) -> Result<Body> {
|
||||
/// k-th edges of all sections share one knot vector and their control points pair up. Face k
|
||||
/// comes from the profile segments under the k-th edge of every section (`numbers`: by
|
||||
/// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`).
|
||||
fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool) -> Result<Body> {
|
||||
let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}"));
|
||||
let edges: Vec<Vec<mt::Edge>> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect();
|
||||
let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?;
|
||||
@@ -921,10 +1012,12 @@ fn smooth_loft(wires: &[mt::Wire]) -> Result<Body> {
|
||||
};
|
||||
// u along the section edge, v across the sections: counter-clockwise in (u, v).
|
||||
let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into();
|
||||
faces.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?);
|
||||
let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?;
|
||||
let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect();
|
||||
faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under))));
|
||||
}
|
||||
let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) };
|
||||
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1))
|
||||
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end)
|
||||
}
|
||||
|
||||
/// A 3D path segment for sweeps.
|
||||
@@ -1006,10 +1099,8 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||||
}
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
||||
for h in ®ion.holes {
|
||||
starts.push(wire(plane, &h.ccw().reversed())?);
|
||||
}
|
||||
let profile = region_wires(plane, &oriented(region)?)?;
|
||||
let starts = wires_of(&profile);
|
||||
guard("sweep", || {
|
||||
let mut shell = mt::Shell::new();
|
||||
let mut ends = Vec::new();
|
||||
@@ -1033,8 +1124,13 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
}
|
||||
ends.push(cur);
|
||||
}
|
||||
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
|
||||
cap_and_close(faces, &starts, &ends)
|
||||
// truck sweeps the faces: each profile edge's run along the whole path, path segment
|
||||
// after segment.
|
||||
let sides: Vec<Side> = match record::columns(shell.face_iter(), &seeds(&profile), None, record::Column::ToEnd, &[]) {
|
||||
Some(prov) => shell.face_iter().cloned().zip(prov).collect(),
|
||||
None => shell.face_iter().map(|f| (f.clone(), None)).collect(),
|
||||
};
|
||||
cap_and_close(sides, &starts, &ends, false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1136,6 +1232,12 @@ pub fn sphere_patches(center: Vec3, radius: f64) -> Result<Body> {
|
||||
|
||||
/// [`sphere_patches`] with the patch layout turned to the frame `rot` (orthonormal columns).
|
||||
pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result<Body> {
|
||||
// Every patch is swept from the arc (segment 0) of the half disc a sphere revolves.
|
||||
sphere_solid(center, radius, rot, &[0])
|
||||
}
|
||||
|
||||
/// [`sphere_patches_rotated`], every patch swept from profile segments `arcs`.
|
||||
fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Result<Body> {
|
||||
let radius = finite(radius, "radius")?;
|
||||
let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z;
|
||||
if radius <= 1e-6 || !center.is_finite() {
|
||||
@@ -1197,6 +1299,6 @@ pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Resu
|
||||
}
|
||||
let shell: mt::Shell = out.into();
|
||||
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
|
||||
Body::new(solid)
|
||||
record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default())
|
||||
})
|
||||
}
|
||||
|
||||
+140
-13
@@ -17,7 +17,8 @@ use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{BoolOp, KernelError, Result, guard};
|
||||
use crate::history::{Change, EdgeOrigin, FaceOrigin, FaceProv, History};
|
||||
use crate::{BoolOp, KernelError, Result, guard, record};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PlaneFace {
|
||||
@@ -41,7 +42,8 @@ fn planes(b: &Body) -> Vec<(usize, PlaneFace)> {
|
||||
}
|
||||
|
||||
/// Push the planar faces of `b` lying in the plane (n, d) — n their outward normal — along n
|
||||
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane.
|
||||
/// by `delta` (negative: inward). Their neighbours must stand perpendicular to the plane. The
|
||||
/// result's history is over `[b]`.
|
||||
pub(crate) fn push_faces(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<Body> {
|
||||
// The exact rebuild, unless it came out unsound (a curved side it couldn't re-sweep).
|
||||
match push_faces_exact(b, n, d, delta)
|
||||
@@ -75,6 +77,9 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
|
||||
let fail = |m: &str| KernelError::Failed(format!("pushing a face: {m}"));
|
||||
guard("push face", || {
|
||||
let solid = b.deep_copy();
|
||||
// The copy's edges are b's, in order; the faces come out in place, one for one.
|
||||
let mut edge_origins = record::edge_origins(&solid, 0);
|
||||
let mut faces_from = Vec::new();
|
||||
let shift = n * delta;
|
||||
let mat = mt::Matrix4::from_translation(v3(shift));
|
||||
let mut shells = Vec::new();
|
||||
@@ -89,7 +94,9 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
|
||||
_ => false,
|
||||
};
|
||||
let moving: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(on_plane)).collect();
|
||||
let first = faces_from.len();
|
||||
if moving.is_empty() {
|
||||
faces_from.extend((first..first + faces.len()).map(|i| record::from_face(0, i, Change::Kept)));
|
||||
shells.push(sh.clone());
|
||||
continue;
|
||||
}
|
||||
@@ -161,10 +168,18 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// Each moved or stretched edge stands for the one it replaces.
|
||||
for (old, new) in &emap {
|
||||
if let Some(o) = edge_origins.get(old).cloned() {
|
||||
edge_origins.insert(new.id(), o);
|
||||
}
|
||||
}
|
||||
use mt::{ParametricSurface3D, SearchNearestParameter};
|
||||
let mut out: Vec<mt::Face> = Vec::new();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let touches = f.edge_iter().any(|e| emap.contains_key(&e.id()));
|
||||
let change = if touches { Change::Modified } else { Change::Kept };
|
||||
faces_from.push(record::from_face(0, first + i, change));
|
||||
if !touches {
|
||||
out.push(f.clone());
|
||||
continue;
|
||||
@@ -208,7 +223,7 @@ pub(crate) fn push_faces_exact(b: &Body, n: Vec3, d: f64, delta: f64) -> Result<
|
||||
shells.push(out.into());
|
||||
}
|
||||
let s = Solid::try_new(shells).map_err(|e| fail(&e.to_string()))?;
|
||||
Body::new(s)
|
||||
record::build(s, &record::counts(&[b]), Some(faces_from), Some(&edge_origins))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,13 +272,18 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
return None;
|
||||
}
|
||||
let (d1, d2) = (size * 0.0137, size * 0.0229);
|
||||
let counts = record::counts(&[a, b]);
|
||||
let run = || -> Result<Option<Body>> {
|
||||
let (mut a2, mut b2) = (a.clone(), b.clone());
|
||||
// What the pushed bodies are made of: their histories over [a] and [b].
|
||||
let (mut ha, mut hb) = (crate::boolean_record::kept(a, 0, 1), crate::boolean_record::kept(b, 0, 1));
|
||||
for (n, d) in &same {
|
||||
// b's face points along n too.
|
||||
b2 = push_faces(&b2, *n, *d, d2)?;
|
||||
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
|
||||
if op == BoolOp::Union {
|
||||
a2 = push_faces(&a2, *n, *d, d1)?;
|
||||
ha = crate::boolean_record::through(&a2, 0, ha.as_ref());
|
||||
}
|
||||
}
|
||||
for (n, d) in &back {
|
||||
@@ -285,22 +305,78 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
|
||||
-d2
|
||||
};
|
||||
b2 = push_faces(&b2, -*n, -*d, shift)?;
|
||||
hb = crate::boolean_record::through(&b2, 0, hb.as_ref());
|
||||
}
|
||||
let Some(mut r) = crate::ops::boolean(&a2, &b2, op)? else { return Ok(None) };
|
||||
// The pushed bodies' histories as over [a, b], a's and b's in their places.
|
||||
let (ha, hb) = (crate::boolean_record::placed(ha, 0, 2), crate::boolean_record::placed(hb, 1, 2));
|
||||
let mut h = crate::boolean_record::of_pair(&r, &ha, &hb);
|
||||
if op == BoolOp::Union {
|
||||
for (n, d) in &same {
|
||||
let slab = slab_beyond(*n, *d, d1.max(d2) * 3.0, size * 4.0)?;
|
||||
match crate::ops::boolean(&r, &slab, BoolOp::Cut)? {
|
||||
Some(x) => r = x,
|
||||
Some(x) => {
|
||||
h = h.and_then(|h| trimmed(&x, h, &slab, [a, b], *n, *d, tol));
|
||||
r = x;
|
||||
}
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(r))
|
||||
Ok(Some(r.with_checked_history(h, &counts)))
|
||||
};
|
||||
Some(run())
|
||||
}
|
||||
|
||||
/// The history over `[a, b]` of `x`, the union `r` of `a` and `b` (with history `h` over them),
|
||||
/// pushed out past the plane (n, d), with the slab beyond it (`slab`, whose start cap lies in
|
||||
/// the plane) cut off. The slab's cap in `x` is where the operands' faces in that plane were
|
||||
/// (all of them, so only a cap that stays one face says which), and where it meets a side of
|
||||
/// an operand is that side's edge along one of those faces. `None` where that is not so.
|
||||
fn trimmed(x: &Body, h: History, slab: &Body, operands: [&Body; 2], n: Vec3, d: f64, tol: f64) -> Option<History> {
|
||||
let mut cut = x.history()?.clone();
|
||||
cut.substitute(0, &h).ok()?;
|
||||
let cap = slab.history()?.faces.iter().position(|f| f.origins == [FaceOrigin::Cap { end: false }])?;
|
||||
let cap = FaceOrigin::Face { operand: 2, face: cap };
|
||||
// The operands' faces in the plane, facing along n, and their edges with each other face.
|
||||
let in_plane: Vec<Vec<usize>> = operands
|
||||
.iter()
|
||||
.map(|o| planes(o).into_iter().filter(|(_, p)| p.n.dot(n) > 1.0 - 1e-9 && (p.d - d).abs() < tol * 10.0).map(|(i, _)| i).collect())
|
||||
.collect();
|
||||
let bounds: Vec<Vec<Vec<usize>>> = operands.iter().map(|o| crate::history::edge_faces(&o.solid)).collect();
|
||||
if cut.faces.iter().filter(|f| f.origins.contains(&cap)).count() != 1 {
|
||||
return None;
|
||||
}
|
||||
for f in &mut cut.faces {
|
||||
if f.origins.contains(&cap) {
|
||||
let mut origins: std::collections::BTreeSet<FaceOrigin> = f.origins.iter().copied().filter(|o| *o != cap).collect();
|
||||
for (operand, faces) in in_plane.iter().enumerate() {
|
||||
origins.extend(faces.iter().map(|face| FaceOrigin::Face { operand, face: *face }));
|
||||
}
|
||||
f.change = if origins.len() > 1 { Change::Merged } else { Change::Modified };
|
||||
f.origins = origins.into_iter().collect();
|
||||
}
|
||||
}
|
||||
let cap = crate::history::Side::from(cap);
|
||||
for e in &mut cut.edges {
|
||||
for o in &mut e.origins {
|
||||
let side = match o {
|
||||
EdgeOrigin::Meet { a, b } if *a == cap => b,
|
||||
EdgeOrigin::Meet { a, b } if *b == cap => a,
|
||||
_ => continue,
|
||||
};
|
||||
let Some(FaceOrigin::Face { operand, face }) = side.single() else { return None };
|
||||
let (plane_faces, bounds) = (in_plane.get(operand)?, bounds.get(operand)?);
|
||||
let mut along = (0..bounds.len())
|
||||
.filter(|k| bounds.get(*k).is_some_and(|fs| fs.len() == 2 && fs.contains(&face) && fs.iter().any(|f| plane_faces.contains(f))));
|
||||
let (Some(edge), None) = (along.next(), along.next()) else { return None };
|
||||
*o = EdgeOrigin::Edge { operand, edge };
|
||||
}
|
||||
}
|
||||
cut.renumber(&[Some(0), Some(1), None], 2).ok()?;
|
||||
Some(cut)
|
||||
}
|
||||
|
||||
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
|
||||
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
|
||||
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
|
||||
@@ -398,6 +474,17 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
edges.insert(e.id(), mt::Edge::new(&v0, &v1, e.curve()));
|
||||
}
|
||||
let mut faces: Vec<mt::Face> = fa_list.iter().enumerate().filter(|(i, _)| i != ia).map(|(_, f)| (*f).clone()).collect();
|
||||
// Every face but the two glued is kept; an edge along the seam is an edge of both.
|
||||
let kept = |operand: usize, n: usize, skip: usize| {
|
||||
(0..n).filter(move |i| *i != skip).map(move |i| record::from_face(operand, i, Change::Kept))
|
||||
};
|
||||
let from: Vec<FaceProv> = kept(0, fa_list.len(), *ia).chain(kept(1, fb_list.len(), *ib)).collect();
|
||||
let mut origins = record::edge_origins(&a.solid, 0);
|
||||
for (old, o) in record::edge_origins(&b.solid, 1) {
|
||||
if let Some(new) = edges.get(&old) {
|
||||
origins.entry(new.id()).or_default().extend(o);
|
||||
}
|
||||
}
|
||||
for (i, f) in fb_list.iter().enumerate() {
|
||||
if i == *ib {
|
||||
continue;
|
||||
@@ -419,7 +506,9 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
|
||||
faces.push(nf);
|
||||
}
|
||||
let solid = Solid::try_new(vec![faces.into()]).map_err(|e| fail(&e.to_string()))?;
|
||||
Body::new(crate::heal::heal(solid, size))
|
||||
// Each edge's origins in the solid's edge order.
|
||||
let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect();
|
||||
crate::boolean_record::heal_build(solid, size, &[], Some(from), origins, &record::counts(&[a, b]))
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -450,6 +539,33 @@ fn same_cylinder(x: &CylFace, y: &CylFace, tol: f64) -> bool {
|
||||
x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < tol && (off - x.axis * off.dot(x.axis)).len() < tol
|
||||
}
|
||||
|
||||
/// The history over `[a, b]` of `r`, made in [`bore_union`]: `joined` (the target joined with
|
||||
/// `narrowed`, the tool with its bore narrowed) less `cut`, the cylinder whose wall restores the
|
||||
/// tool's bore faces `bore` (the tool is operand `tool_operand`). Nothing of the cylinder but its
|
||||
/// wall may be left. Only a bore of one face: the cylinder's wall faces are split where its own
|
||||
/// construction splits them, so which of several bore faces a piece of it lies over is not known.
|
||||
fn recut(r: &Body, joined: &Body, narrowed: &Body, cut: &Body, bore: &[usize], tool_operand: usize) -> Option<History> {
|
||||
if bore.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
// Over [target, tool].
|
||||
let joined = crate::boolean_record::through(joined, 1, narrowed.history())?;
|
||||
let wall = || record::region(bore.iter().map(|&face| FaceOrigin::Face { operand: 1, face }).collect(), false);
|
||||
let faces = cut
|
||||
.history()?
|
||||
.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.first() {
|
||||
Some(FaceOrigin::Profile { .. }) => wall(),
|
||||
_ => record::generated(Vec::new()),
|
||||
})
|
||||
.collect();
|
||||
let cut = record::from_faces(&cut.solid, 2, faces);
|
||||
let h = crate::boolean_record::of_pair(r, &Some(joined), &Some(cut))?;
|
||||
let map = if tool_operand == 1 { [Some(0), Some(1)] } else { [Some(1), Some(0)] };
|
||||
crate::boolean_record::composed(h.renumbered(&map, 2))
|
||||
}
|
||||
|
||||
/// Do the bodies have cylindrical faces on a common cylinder?
|
||||
pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
|
||||
let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0;
|
||||
@@ -465,7 +581,8 @@ pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
|
||||
/// samples are plausibility checks; a whole-boundary guard rejects additional boundaries
|
||||
/// crossing the recut. Original common-bore trims retain the legacy eligibility policy.
|
||||
/// This is not a general emptiness or Boolean-equivalence certificate. Each direction
|
||||
/// returns a result only when `accept` takes it; otherwise this returns `None`.
|
||||
/// returns a result only when `accept` takes it; otherwise this returns `None`. The result's
|
||||
/// history is over `[a, b]`.
|
||||
pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
|
||||
let size = a.size().max(b.size());
|
||||
let tol = (size * 1e-7).max(1e-9) * 10.0;
|
||||
@@ -474,9 +591,10 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
|
||||
let (ia, ib) = (ma.inside_index(), mb.inside_index());
|
||||
let empty = |p: Vec3| !ia.contains(p) && !ib.contains(p);
|
||||
let (ca, cb) = (cylinders(a), cylinders(b));
|
||||
// (body to narrow, its mesh and inside test, the other body, their faces on the cylinder).
|
||||
let ways = [(b, &mb, &ib, a, &cb, &ca), (a, &ma, &ia, b, &ca, &cb)];
|
||||
for (tool, mesh, inside, target, ct, co) in ways {
|
||||
// (body to narrow, its operand number, its mesh and inside test, the other body, their faces
|
||||
// on the cylinder).
|
||||
let ways = [(b, 1, &mb, &ib, a, &cb, &ca), (a, 0, &ma, &ia, b, &ca, &cb)];
|
||||
for (tool, tool_operand, mesh, inside, target, ct, co) in ways {
|
||||
for c in ct {
|
||||
if !co.iter().any(|o| same_cylinder(c, o, tol)) {
|
||||
continue;
|
||||
@@ -555,10 +673,19 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
|
||||
}
|
||||
let Some(joined) = crate::ops::boolean(target, &narrowed, BoolOp::Union)? else { return Ok(None) };
|
||||
let cut = crate::build::cylinder(at(lo, 0.0, 0.0), c.axis, c.radius, hi - lo)?;
|
||||
crate::ops::boolean(&joined, &cut, BoolOp::Cut)
|
||||
let Some(r) = crate::ops::boolean(&joined, &cut, BoolOp::Cut)? else { return Ok(None) };
|
||||
let h = recut(&r, &joined, &narrowed, &cut, &bore, tool_operand);
|
||||
Ok(Some(r.with_checked_history(h, &record::counts(&[a, b]))))
|
||||
};
|
||||
match run() {
|
||||
Ok(Some(r)) if accept(&r) => return Some(r),
|
||||
let (result, diagnostics) = crate::capture_boolean_diagnostics(run);
|
||||
match result {
|
||||
Ok(Some(r)) if accept(&r) => {
|
||||
crate::boolean_diagnostics::merge(diagnostics);
|
||||
let mut event = crate::BooleanFallback::new(crate::BooleanStrategy::CommonBore);
|
||||
event.face_shift_mm = d;
|
||||
crate::boolean_diagnostics::record(event);
|
||||
return Some(r);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
//! blend's radius rolls along the edge touching both sides; its centre traces the spine and its
|
||||
//! contacts the curves where the blend meets each side. The blend face is a rational B-spline
|
||||
//! through the circular cross-sections (exact arcs at the samples), the side faces are trimmed
|
||||
//! back to the contact curves, and seams that met the edge are shortened to the contacts.
|
||||
//! back to the contact curves, and seams that met the edge (lines, or arcs of an end face) are
|
||||
//! shortened to the contacts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -11,6 +12,7 @@ use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Solid, from_p3, p3};
|
||||
use crate::record::Rebuild;
|
||||
use crate::{KernelError, Result};
|
||||
|
||||
fn unsupported(msg: &str) -> KernelError {
|
||||
@@ -176,14 +178,47 @@ fn project(f: &mt::Face, p: Vec3, hint: Option<(f64, f64)>) -> Option<(Vec3, Vec
|
||||
Some((q, Vec3::new(n.x, n.y, n.z).normalized()?, uv))
|
||||
}
|
||||
|
||||
/// Whether `uv` lies within the parameters of the face's surface (unbounded directions pass).
|
||||
fn in_range(f: &mt::Face, uv: (f64, f64)) -> bool {
|
||||
use mt::ParametricSurface;
|
||||
use std::ops::Bound;
|
||||
let inside = |range: (Bound<f64>, Bound<f64>), x: f64| {
|
||||
let slack = |a: f64, b: f64| (b - a).abs() * 1e-6 + 1e-9;
|
||||
match range {
|
||||
(Bound::Included(a) | Bound::Excluded(a), Bound::Included(b) | Bound::Excluded(b)) => x >= a - slack(a, b) && x <= b + slack(a, b),
|
||||
(Bound::Included(a) | Bound::Excluded(a), Bound::Unbounded) => x >= a - 1e-9,
|
||||
(Bound::Unbounded, Bound::Included(b) | Bound::Excluded(b)) => x <= b + 1e-9,
|
||||
(Bound::Unbounded, Bound::Unbounded) => true,
|
||||
}
|
||||
};
|
||||
let (ru, rv) = f.oriented_surface().parameter_range();
|
||||
uv.0.is_finite() && uv.1.is_finite() && inside(ru, uv.0) && inside(rv, uv.1)
|
||||
}
|
||||
|
||||
/// Solve for the ball touching both sides near `p`. `extra` adds a third condition (a plane the
|
||||
/// side-k contact must lie in); without it the centre stays in the plane through `p` square to
|
||||
/// `t`. `sigma` is +1 when the ball sits outside the material (concave edge), −1 inside.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn solve(f1: &[&mt::Face], f2: &[&mt::Face], p: Vec3, t: Vec3, r: f64, sigma: f64, extra: Option<(usize, Vec3, Vec3)>, tol: f64) -> Option<Section> {
|
||||
// The nearest face of a side (several faces on one smooth surface group).
|
||||
// The nearest face of a side (several faces on one smooth surface group). Each face's
|
||||
// search starts from its last answer: the centre moves little between iterations. An
|
||||
// answer outside the surface's parameters, or farther than that last point (the search
|
||||
// ran away or settled on the far side), is searched again cold.
|
||||
let last = std::cell::RefCell::new(HashMap::<mt::FaceID, ((f64, f64), Vec3)>::new());
|
||||
let near = |fs: &[&mt::Face], c: Vec3| -> Option<(Vec3, Vec3)> {
|
||||
fs.iter().filter_map(|f| project(f, c, None).map(|(q, n, _)| (q, n))).min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c)))
|
||||
fs.iter()
|
||||
.filter_map(|f| {
|
||||
let prev = last.borrow().get(&f.id()).copied();
|
||||
let warm =
|
||||
prev.and_then(|(uv, q)| project(f, c, Some(uv)).filter(|(x, _, uv)| in_range(f, *uv) && x.dist(c) <= q.dist(c) + tol * 1e-3));
|
||||
let (q, n, uv) = match warm {
|
||||
Some(x) => x,
|
||||
None => project(f, c, None)?,
|
||||
};
|
||||
last.borrow_mut().insert(f.id(), (uv, q));
|
||||
Some((q, n))
|
||||
})
|
||||
.min_by(|a, b| a.0.dist(c).total_cmp(&b.0.dist(c)))
|
||||
};
|
||||
let (q1, n1) = near(f1, p)?;
|
||||
let (q2, n2) = near(f2, p)?;
|
||||
@@ -238,12 +273,57 @@ fn section_controls(s: &Section, round: bool) -> Option<[mt::Vector4; 3]> {
|
||||
Some([h(s.q1, 1.0), h(m, half), h(s.q2, 1.0)])
|
||||
}
|
||||
|
||||
/// Blend the closed chain of edges (ids in any order) with radius `r`.
|
||||
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<Solid> {
|
||||
/// A curved seam (an arc of an end face) shortened to the blend's contacts: the part of its
|
||||
/// curve between the new ends `a` and `b` (`None` keeps that end), which must lie on it, in
|
||||
/// order, short of each other.
|
||||
fn trim_curved_seam(seam: &mt::Edge, a: Option<mt::Vertex>, b: Option<mt::Vertex>, size: f64) -> Result<mt::Edge> {
|
||||
// A closed seam (a full circle starting and ending at one vertex) has no "between": which
|
||||
// end a contact replaces, and which way round the kept piece runs, are both ambiguous.
|
||||
if seam.front() == seam.back() {
|
||||
return Err(unsupported("a closed curved edge where the blend ends"));
|
||||
}
|
||||
use mt::{BoundedCurve, Cut, SearchNearestParameter};
|
||||
let mut curve = seam.curve();
|
||||
let (lo, hi) = curve.range_tuple();
|
||||
let at = |v: &Option<mt::Vertex>, end: f64| -> Result<f64> {
|
||||
let Some(v) = v else { return Ok(end) };
|
||||
// Start from the nearest of a few samples: from the far end of a long arc
|
||||
// the search can settle on the opposite side of the circle.
|
||||
let q = from_p3(v.point());
|
||||
let hint = (0..=32)
|
||||
.map(|i| lo + (hi - lo) * i as f64 / 32.0)
|
||||
.min_by(|x, y| {
|
||||
let d = |t: f64| from_p3(mt::ParametricCurve::subs(&curve, t)).dist(q);
|
||||
d(*x).total_cmp(&d(*y))
|
||||
})
|
||||
.unwrap_or(end);
|
||||
let t = curve.search_nearest_parameter(v.point(), hint, 100).ok_or_else(|| unsupported("the blend is larger than the neighbouring faces"))?;
|
||||
if !(t.is_finite() && from_p3(mt::ParametricCurve::subs(&curve, t)).dist(from_p3(v.point())) <= size * 1e-5) {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
Ok(t)
|
||||
};
|
||||
let (ta, tb) = (at(&a, lo)?, at(&b, hi)?);
|
||||
let margin = (hi - lo) * 1e-9;
|
||||
if ta < lo - margin || tb > hi + margin || tb - ta < (hi - lo) * 1e-6 {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
if tb < hi {
|
||||
let _ = curve.cut(tb);
|
||||
}
|
||||
let piece = if ta > lo { curve.cut(ta) } else { curve };
|
||||
let a = a.unwrap_or_else(|| seam.front().clone());
|
||||
let b = b.unwrap_or_else(|| seam.back().clone());
|
||||
mt::Edge::try_new(&a, &b, piece).map_err(|e| KernelError::Failed(format!("curve blend: seam: {e}")))
|
||||
}
|
||||
|
||||
/// Blend the closed chain of edges (ids in any order) with radius `r`; with what it built from
|
||||
/// `solid` (each blend face in place of its chain edge).
|
||||
pub(crate) fn curve_blend(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Result<(Solid, Rebuild)> {
|
||||
curve_blend_with_limit(solid, ids, size, r, round, MAX_SAMPLES)
|
||||
}
|
||||
|
||||
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<Solid> {
|
||||
fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64, round: bool, max_samples: usize) -> Result<(Solid, Rebuild)> {
|
||||
if !(size.is_finite() && size > 0.0 && r.is_finite() && r > 0.0) {
|
||||
return Err(KernelError::Invalid("curve blend size and radius must be finite and positive".into()));
|
||||
}
|
||||
@@ -598,7 +678,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
let mut seam_subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (id, (seam, a, b)) in ends {
|
||||
if !matches!(seam.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("a curved edge where the blend ends"));
|
||||
seam_subst.insert(id, trim_curved_seam(&seam, a, b, size)?);
|
||||
continue;
|
||||
}
|
||||
// The new ends must stay on the seam, in order, short of each other.
|
||||
let (p0, p1) = (from_p3(seam.front().point()), from_p3(seam.back().point()));
|
||||
@@ -617,6 +698,8 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
seam_subst.insert(id, builder::line(&a, &b));
|
||||
}
|
||||
let mut out: Vec<mt::Face> = Vec::new();
|
||||
let mut rec = Rebuild::default();
|
||||
rec.pieces(&seam_subst);
|
||||
for (fi, f) in faces.iter().enumerate() {
|
||||
let corners: Vec<usize> = end_faces.iter().filter(|(_, g)| *g == fi).map(|(k, _)| *k).collect();
|
||||
if !corners.is_empty() {
|
||||
@@ -655,7 +738,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?);
|
||||
let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("an end face could not be rebuilt"))?;
|
||||
rec.face(&rebuilt, f);
|
||||
out.push(rebuilt);
|
||||
continue;
|
||||
}
|
||||
let on1 = side1.contains(&fi);
|
||||
@@ -682,7 +767,9 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
}
|
||||
wires.push(mt::Wire::from(es));
|
||||
}
|
||||
out.push(crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?);
|
||||
let rebuilt = crate::heal::absolute_face(f, wires).ok_or_else(|| fail("a side face could not be rebuilt"))?;
|
||||
rec.face(&rebuilt, f);
|
||||
out.push(rebuilt);
|
||||
}
|
||||
// Blend faces: each runs its side-1 contact the other way from side 1's face.
|
||||
for (k, bf) in blends.iter().enumerate() {
|
||||
@@ -706,13 +793,14 @@ fn curve_blend_with_limit(solid: &Solid, ids: &[mt::EdgeID], size: f64, r: f64,
|
||||
vec![e1.clone(), ak1.clone(), e2.inverse(), ak.inverse()].into()
|
||||
};
|
||||
let face = mt::Face::try_new(vec![wire], bf.clone()).map_err(|e| fail(&e.to_string()))?;
|
||||
rec.blend(&face, [chain_e.id()]);
|
||||
out.push(face);
|
||||
}
|
||||
let mut shells = solid.boundaries().clone();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = out.into();
|
||||
}
|
||||
Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))
|
||||
Ok((Solid::try_new(shells).map_err(|e| fail(&format!("invalid solid: {e}")))?, rec))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -192,3 +192,110 @@ fn rolling_ball_refinement_cap_returns_an_error_and_preserves_input() {
|
||||
assert_eq!(crate::step_export(&[&body], "unchanged").unwrap(), before);
|
||||
assert_eq!(crate::step_export(&[&Body::new(solid.clone()).unwrap()], "unchanged").unwrap(), input_before);
|
||||
}
|
||||
|
||||
/// Plan-view construction of a round between two overlapping circles (a concave vertical edge
|
||||
/// where two Z cylinders meet): the ball centre `f`, the circle and fillet arc angles, and the
|
||||
/// area the round fills in, all independent of the kernel.
|
||||
struct Gap {
|
||||
/// Points along the round's arc in plan, contacts included.
|
||||
arc: Vec<Vec3>,
|
||||
/// Arc angles on circle 1, circle 2 and the fillet between the corner and the contacts.
|
||||
angles: [f64; 3],
|
||||
area: f64,
|
||||
}
|
||||
|
||||
fn gap(c1: Vec3, r1: f64, c2: Vec3, r2: f64, corner: Vec3, r: f64) -> Gap {
|
||||
// The ball centre is r from both circles, on the corner's side of the centre line.
|
||||
let (a, b) = (r1 + r, r2 + r);
|
||||
let d = c1.dist(c2);
|
||||
let u = (c2 - c1) * (1.0 / d);
|
||||
let along = (a * a - b * b + d * d) / (2.0 * d);
|
||||
let mut side = Vec3::new(-u.y, u.x, 0.0);
|
||||
if (corner - c1).dot(side) < 0.0 {
|
||||
side = side * -1.0;
|
||||
}
|
||||
let f = c1 + u * along + side * (a * a - along * along).sqrt();
|
||||
let (t1, t2) = (c1 + (f - c1) * (r1 / a), c2 + (f - c2) * (r2 / b));
|
||||
let angle = |c: Vec3, p: Vec3, q: Vec3| (p - c).normalized().unwrap().dot((q - c).normalized().unwrap()).clamp(-1.0, 1.0).acos();
|
||||
let angles = [angle(c1, t1, corner), angle(c2, corner, t2), angle(f, t2, t1)];
|
||||
// Shoelace over the three short arcs: circle 1 to the corner, circle 2 on, fillet back.
|
||||
let mut ring = Vec::new();
|
||||
for (c, p, q) in [(c1, t1, corner), (c2, corner, t2), (f, t2, t1)] {
|
||||
let (p0, q0) = (p - c, q - c);
|
||||
let (a0, a1) = (p0.y.atan2(p0.x), q0.y.atan2(q0.x));
|
||||
let mut sweep = a1 - a0;
|
||||
while sweep > std::f64::consts::PI {
|
||||
sweep -= std::f64::consts::TAU;
|
||||
}
|
||||
while sweep < -std::f64::consts::PI {
|
||||
sweep += std::f64::consts::TAU;
|
||||
}
|
||||
let rr = p0.len();
|
||||
for i in 0..20_000 {
|
||||
let t = a0 + sweep * i as f64 / 20_000.0;
|
||||
ring.push(c + Vec3::new(t.cos(), t.sin(), 0.0) * rr);
|
||||
}
|
||||
}
|
||||
let area = ring.iter().zip(ring.iter().cycle().skip(1)).map(|(p, q)| p.x * q.y - q.x * p.y).sum::<f64>().abs() * 0.5;
|
||||
let (p, q) = ((t1 - f).normalized().unwrap(), (t2 - f).normalized().unwrap());
|
||||
let arc = (0..=8).map(|i| angles[2] * i as f64 / 8.0).map(|t| f + (p * ((angles[2] - t).sin()) + q * t.sin()) * (r / angles[2].sin())).collect();
|
||||
Gap { arc, angles, area }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_ball_along_a_straight_edge_between_cylinders_trims_curved_end_seams() {
|
||||
// Two Z cylinders crossing square to each other: the vertical lines where they meet are
|
||||
// concave edges between two curved faces, and the blend ends on the planar caps, where the
|
||||
// seams are arcs.
|
||||
let (r1, r2, h, r) = (17.0, 9.5, 10.0, 6.0);
|
||||
let (c1, c2) = (Vec3::ZERO, Vec3::new(r1, r2, 0.0));
|
||||
let a = cylinder(c1, Vec3::Z, r1, h).unwrap();
|
||||
let b = cylinder(c2, Vec3::Z, r2, h).unwrap();
|
||||
let body = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
|
||||
// The corner: one of the two points where the circles cross.
|
||||
let d = c1.dist(c2);
|
||||
let u = (c2 - c1) * (1.0 / d);
|
||||
let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let corner = c1 + u * along + Vec3::new(-u.y, u.x, 0.0) * (r1 * r1 - along * along).sqrt();
|
||||
let g = gap(c1, r1, c2, r2, corner, r);
|
||||
let blend = fillet(&body, &[corner + Vec3::Z * (h / 2.0)], r).unwrap();
|
||||
assert!(blend.validity().is_empty(), "{:?}", blend.validity());
|
||||
// One blend face; the edge is replaced by two contact lines and two end arcs.
|
||||
let (before, after) = (crate::measure(&body).unwrap(), crate::measure(&blend).unwrap());
|
||||
assert_eq!((after.faces, after.edges, after.vertices), (before.faces + 1, before.edges + 3, before.vertices + 2), "{before:?} -> {after:?}");
|
||||
// The round fills the gap over the full height. The caps gain it twice; the walls lose
|
||||
// their arcs to the contacts and gain the round.
|
||||
let (m0, m1) = (body.tessellate_with(0.0005, true).unwrap().measure(), blend.tessellate_with(0.0005, true).unwrap().measure());
|
||||
let volume = h * g.area;
|
||||
assert!((m1.volume - m0.volume - volume).abs() < volume * 3e-3, "added volume {} vs {volume}", m1.volume - m0.volume);
|
||||
let [a1, a2, af] = g.angles;
|
||||
let area = 2.0 * g.area + h * (r * af - r1 * a1 - r2 * a2);
|
||||
assert!((m1.area - m0.area - area).abs() < area.abs() * 3e-3, "added area {} vs {area}", m1.area - m0.area);
|
||||
// The round is the analytic arc at every height, from contact to contact.
|
||||
let surfaces: Vec<_> = blend.solid.face_iter().map(|f| f.oriented_surface()).filter(|s| matches!(s, mt::Surface::NurbsSurface(_))).collect();
|
||||
let mut worst: f64 = 0.0;
|
||||
for z in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
for p in &g.arc {
|
||||
worst = worst.max(nearest_blend(&surfaces, *p + Vec3::Z * (h * z)).0);
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_curved_seam_is_not_supported() {
|
||||
// A closed seam (one edge starting and ending at the same vertex, as a full cap circle can
|
||||
// be in imported geometry) has no "between" two contacts, so trimming it is refused rather
|
||||
// than guessed.
|
||||
let body = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 5.0).unwrap();
|
||||
let arc = body.solid.edge_iter().find(|e| !matches!(e.curve(), mt::Curve::Line(_))).expect("a cap arc");
|
||||
let circle = mt::Edge::new_unchecked(arc.front(), arc.front(), arc.curve());
|
||||
assert_eq!(circle.front(), circle.back());
|
||||
// Contacts on the circle itself: a third and two thirds of the way round.
|
||||
let (lo, hi) = circle.curve().range_tuple();
|
||||
let on = |f: f64| mt::Vertex::new(circle.curve().subs(lo + (hi - lo) * f));
|
||||
for (a, b) in [(Some(on(1.0 / 3.0)), None), (None, Some(on(2.0 / 3.0))), (Some(on(1.0 / 3.0)), Some(on(2.0 / 3.0))), (None, None)] {
|
||||
let err = trim_curved_seam(&circle, a, b, 20.0).unwrap_err();
|
||||
assert!(err.to_string().contains("not supported yet: a closed curved edge"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user