Compare commits
102
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6090e89b3f | ||
|
|
f06b76e14f | ||
|
|
4b8872e4d0 | ||
|
|
e7b5b2618e | ||
|
|
ef9bb0db09 | ||
|
|
f538eeb5fa | ||
|
|
d470bfc52a | ||
|
|
f82b6beedb | ||
|
|
80672460ed | ||
|
|
57e6396fbb | ||
|
|
a5cc1243e3 | ||
|
|
afcec4d0bd | ||
|
|
6efbd6ff14 | ||
|
|
c51735c885 | ||
|
|
4eaadf3099 | ||
|
|
882ef0e9c6 | ||
|
|
d15869ab85 | ||
|
|
511d105dcb | ||
|
|
2ebceb6918 | ||
|
|
7db66bb824 | ||
|
|
a7e47fdae6 | ||
|
|
ce226a88eb | ||
|
|
0b122e6d4a | ||
|
|
b2fc6d1fac | ||
|
|
e6a95615a0 | ||
|
|
6dab0fb2c1 | ||
|
|
a04ef4ca59 | ||
|
|
8e87a37511 | ||
|
|
fe4d171e22 | ||
|
|
75a5609b23 | ||
|
|
fc8a5b21cf | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solvecraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
|
||||
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solvecraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solvecraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
# Every test's time in the log, so the slow ones are easy to find.
|
||||
status-level = "pass"
|
||||
final-status-level = "slow"
|
||||
failure-output = "immediate-final"
|
||||
slow-timeout = { period = "60s" }
|
||||
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
+26
-15
@@ -3,41 +3,52 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
|
||||
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
|
||||
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
|
||||
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
|
||||
**Renamed to SolidCraft.** Downloads, installers and programs now carry the SolidCraft name:
|
||||
the desktop app is `solidcraft`, the command line `solidcraft-cli`. `solvecraft-cli` is still
|
||||
installed as a deprecated alias that forwards to `solidcraft-cli` (it prints a one-line note on
|
||||
stderr), so scripts and MCP setups such as `claude mcp add solvecraft -- solvecraft-cli mcp`
|
||||
keep working; new setups use `claude mcp add solidcraft -- solidcraft-cli mcp`. Existing designs
|
||||
open as before. Upgrades: the `.msi` upgrades an earlier install in place (it now installs to
|
||||
`Program Files\SolidCraft`); the `solidcraft` `.deb`/`.rpm` replaces the old `solvecraft`
|
||||
package; the Flatpak app ID `ai.storyteller.solvecraft` is unchanged, so Flatpak updates
|
||||
continue; earlier AppImages still find updates.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
|
||||
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
|
||||
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
**Updating macOS from SolveCraft.app:** quit the app, rename the existing app in
|
||||
Applications to `SolidCraft.app`, then drag the new app from the DMG onto Applications
|
||||
and choose **Replace**. If both names are already installed, move the old `SolveCraft.app`
|
||||
to the Trash. Remove any Dock shortcut with the old name and pin the new app. Designs
|
||||
and settings are unchanged. The DMG includes these instructions.
|
||||
|
||||
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 remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft 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`).
|
||||
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
+123
-11
@@ -1,9 +1,15 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cross_compile:
|
||||
@@ -16,7 +22,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Every push to main gets its full run; a new PR push replaces the old run.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -39,7 +46,7 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
native=false
|
||||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
@@ -92,12 +99,12 @@ jobs:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -113,18 +120,117 @@ jobs:
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
# Every test binary at once (cargo test runs them one after another).
|
||||
run: cargo xtask test --locked
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Compile tests into a nextest archive
|
||||
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: build-tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
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: doc-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# nextest does not run doc tests.
|
||||
- run: cargo test --workspace --profile ci --locked --doc
|
||||
|
||||
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 solidcraft-cli --profile ci --locked
|
||||
- name: Replay tests/oracle and compare with expected.json
|
||||
run: cargo xtask oracle --cli target/ci/solidcraft-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, build-tests, tests, doc-tests, oracle]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -134,13 +240,19 @@ jobs:
|
||||
CHECKS: ${{ needs.checks.result }}
|
||||
NATIVE: ${{ needs.changes.outputs.native }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
run: |
|
||||
test "$CHANGES" = success
|
||||
test "$CHECKS" = success
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
test "$TESTS" = success
|
||||
test "$DOCS" = 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
|
||||
@@ -52,8 +52,8 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
@@ -91,10 +91,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
@@ -114,8 +114,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -135,17 +135,22 @@ jobs:
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
|
||||
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
|
||||
rpm -qip dist/release/*.rpm
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solvecraft
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solidcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
# Legacy AppImage update alias (docs/compatibility.md).
|
||||
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
@@ -166,8 +171,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -181,7 +186,7 @@ jobs:
|
||||
# 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
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
@@ -198,8 +203,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
@@ -211,7 +216,7 @@ jobs:
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
@@ -221,10 +226,10 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
@@ -237,8 +242,8 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -269,7 +274,7 @@ jobs:
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -290,7 +295,7 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -304,7 +309,7 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -16,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
|
||||
@@ -43,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
|
||||
@@ -71,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`).
|
||||
|
||||
@@ -111,7 +115,7 @@ version bumps solely from commit prefixes, overwrite published versions, or push
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
@@ -121,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`).
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
- 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 solidcraft`.
|
||||
|
||||
## 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.
|
||||
+22
-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,7 +76,7 @@ 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; degenerate intersection-curve rejection; 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
|
||||
|
||||
@@ -16,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
|
||||
@@ -43,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
|
||||
@@ -71,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`).
|
||||
|
||||
@@ -88,7 +92,7 @@ version bumps solely from commit prefixes, overwrite published versions, or push
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
@@ -98,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`).
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
- 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 solidcraft`.
|
||||
|
||||
## 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.
|
||||
+40
-16
@@ -17,23 +17,23 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft -- --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`
|
||||
Build the desktop app with `cargo build --release -p solidcraft`, or the CLI with
|
||||
`cargo build --release -p solidcraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-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.
|
||||
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
@@ -88,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
|
||||
@@ -119,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.
|
||||
@@ -138,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
|
||||
@@ -146,11 +153,28 @@ 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.
|
||||
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
|
||||
quarter each, so every test runs in its own process: don't rely on state left by another test.
|
||||
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
|
||||
`main` and nightly; check the `main` run after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solvecraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness`,
|
||||
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
|
||||
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
|
||||
- `solvecraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
- `solvecraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**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
+16
-15
@@ -3289,8 +3289,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3306,8 +3306,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-cli"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3316,7 +3316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3331,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3347,15 +3347,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3367,7 +3368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3382,7 +3383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3390,7 +3391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3400,7 +3401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3411,7 +3412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3426,7 +3427,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4849,7 +4850,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.2"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,10 +17,11 @@ developer guide and command reference; sources in [docs/book](docs/book)), and t
|
||||
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
|
||||
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
**Status: early but usable for simple parts.** The historical benchmark report records
|
||||
76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)).
|
||||
These checks do not establish complete geometric or workflow equivalence. Feature coverage
|
||||
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
|
||||
**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).
|
||||
|
||||
@@ -32,6 +33,9 @@ Cargo package names, settings/recovery locations and package IDs keep their exis
|
||||
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
|
||||
and filenames.
|
||||
|
||||
On macOS, the DMG contains `SolidCraft.app`. To replace an older `SolveCraft.app`
|
||||
without installing a second copy, follow the [macOS upgrade instructions](https://bherbruck.github.io/solidcraft/guide/getting-started.html#macos).
|
||||
|
||||
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
|
||||
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
|
||||
|
||||
@@ -77,7 +81,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
|
||||
@@ -95,22 +100,36 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solvecraft-cli -- commands # the command registry
|
||||
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse.
|
||||
|
||||
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
|
||||
device. A udev rule that adds the `uaccess` tag does that, for example
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
|
||||
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
|
||||
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
|
||||
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
|
||||
report descriptor, so they work as well.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
@@ -166,6 +185,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.
|
||||
+67
-18
@@ -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 | **76 / 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. |
|
||||
| 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,24 +37,23 @@ 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
|
||||
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| 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 |
|
||||
| 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. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. 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,30 @@ 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.
|
||||
@@ -119,6 +137,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## View navigation
|
||||
|
||||
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
|
||||
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
|
||||
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
|
||||
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
|
||||
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
|
||||
@@ -144,7 +164,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;
|
||||
@@ -158,16 +179,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
|
||||
@@ -234,6 +254,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.
|
||||
@@ -286,14 +310,33 @@ 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
|
||||
@@ -307,6 +350,12 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -13345,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.
|
||||
@@ -1,17 +1,24 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
name = "solidcraft-cli"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "solidcraft-cli"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
name = "solidcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
|
||||
# note on stderr. Shipped in every package for at least one release.
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-mcp = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
|
||||
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
|
||||
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
|
||||
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
|
||||
//! stays clean for MCP's JSON-RPC), and exits with its status.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const TARGET: &str = "solidcraft-cli";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
let sibling = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
|
||||
.filter(|p| p.is_file());
|
||||
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
|
||||
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
|
||||
Ok(status) => match status.code() {
|
||||
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
|
||||
None => ExitCode::FAILURE,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//!
|
||||
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
|
||||
//! is recorded like any other failure.
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
//! SolveCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! 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 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)
|
||||
//! solidcraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -20,7 +19,7 @@
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -32,20 +31,19 @@ use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
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
|
||||
solidcraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solidcraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -73,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..]),
|
||||
@@ -90,7 +87,7 @@ fn main() -> ExitCode {
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
@@ -289,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()),
|
||||
};
|
||||
@@ -302,7 +305,7 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
/// parameter `save_round` set to the round number each time.
|
||||
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
|
||||
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
|
||||
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
@@ -324,7 +327,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
|
||||
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
|
||||
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
};
|
||||
let v = read_json(path)?;
|
||||
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
||||
@@ -352,7 +355,7 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let commands = registry.as_array().unwrap();
|
||||
@@ -16,7 +16,7 @@ fn registry_output_is_complete_json() {
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// Close our read end before waiting. No sleeps or timing threshold are needed:
|
||||
// the registry is larger than the pipe buffer, so it cannot all be written first.
|
||||
drop(child.stdout.take());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
|
||||
@@ -15,14 +15,14 @@ fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_seed_runs_once_without_wrapping() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
@@ -12,7 +12,7 @@ fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_forwards_with_a_deprecation_note() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_passes_the_exit_status_through() {
|
||||
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
|
||||
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
|
||||
assert_eq!(alias.status.code(), real.status.code());
|
||||
assert_eq!(alias.stdout, real.stdout);
|
||||
}
|
||||
@@ -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_solidcraft-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}");
|
||||
}
|
||||
@@ -31,7 +31,7 @@ impl Drop for Scratch {
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
|
||||
@@ -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)),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
name = "solidcraft"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
default-run = "solidcraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -19,9 +19,11 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
quick-xml = { 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"] }
|
||||
|
||||
@@ -56,6 +56,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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()?;
|
||||
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
+54
-11
@@ -15,7 +15,7 @@
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod space_mouse;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
@@ -23,7 +23,7 @@ use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ impl eframe::App for App {
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -155,11 +155,11 @@ fn main() -> eframe::Result {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
@@ -175,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();
|
||||
@@ -198,6 +200,8 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
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);
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -225,17 +229,56 @@ fn main() -> eframe::Result {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
|
||||
//! it publishes the latest axes, and the egui thread applies them once per frame.
|
||||
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
|
||||
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
|
||||
//! applies them once per frame.
|
||||
|
||||
mod hid_descriptor;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod hidraw;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hid_descriptor::Layout;
|
||||
#[cfg(target_os = "windows")]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
@@ -208,9 +215,14 @@ impl Reading {
|
||||
}
|
||||
|
||||
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
|
||||
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
|
||||
matches!(vendor_id, 0x046d | 0x256f)
|
||||
}
|
||||
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
|
||||
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
@@ -220,8 +232,18 @@ fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
|
||||
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
|
||||
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
|
||||
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
|
||||
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
|
||||
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
Some(1) if layout == Layout::Combined => {
|
||||
let Some(body) = report.get(1..) else { return false };
|
||||
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.translation_at = now;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
Some(1) => {
|
||||
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.translation = translation;
|
||||
@@ -238,6 +260,24 @@ fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The device is gone: stop reporting its last position.
|
||||
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
@@ -247,27 +287,72 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
|
||||
let layout = device_layout(&device);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(&report[..size], &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
/// How the device packs its axes, from its report descriptor; the usual split layout if the
|
||||
/// descriptor cannot be read.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn device_layout(device: &hidapi::HidDevice) -> Layout {
|
||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||
match device.get_report_descriptor(&mut descriptor) {
|
||||
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
|
||||
Layout::Split
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
|
||||
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
|
||||
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
use std::io::Read;
|
||||
let mut denied = None;
|
||||
loop {
|
||||
if let Some(device) = hidraw::find() {
|
||||
match std::fs::File::open(&device.path) {
|
||||
Ok(mut file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
// Say so once per device node, not every retry.
|
||||
Err(error) if denied.as_ref() != Some(&device.path) => {
|
||||
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
|
||||
denied = Some(device.path);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
@@ -371,19 +456,46 @@ mod tests {
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
|
||||
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert_eq!(reading.current(later), Axes::default());
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_report_one_carries_translation_and_rotation_together() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
|
||||
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
|
||||
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
|
||||
// The same bytes on a split device are only the translation report; its padding is not rotation.
|
||||
let mut split = Reading::new(now);
|
||||
assert!(decode_report(&report, Layout::Split, &mut split, now));
|
||||
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_layout_ignores_short_and_other_reports() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
// Extra padding after the six values is fine.
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
|
||||
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
|
||||
//! it simply yields less information.
|
||||
|
||||
/// Generic Desktop usage page, and the usages in it that matter here.
|
||||
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
|
||||
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
|
||||
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
|
||||
const AXIS_COUNT: u32 = 6;
|
||||
|
||||
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
|
||||
const MOTION_REPORT_ID: u8 = 1;
|
||||
|
||||
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
|
||||
const MAX_DESCRIPTOR_BYTES: usize = 4096;
|
||||
/// Bound on the usages one main item can declare through a usage range.
|
||||
const MAX_USAGES_PER_ITEM: u32 = 256;
|
||||
|
||||
/// How a device spreads its six motion axes over input reports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
|
||||
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
|
||||
#[default]
|
||||
Split,
|
||||
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
|
||||
Combined,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Item {
|
||||
/// 0 main, 1 global, 2 local (3 is reserved).
|
||||
kind: u8,
|
||||
tag: u8,
|
||||
value: u32,
|
||||
}
|
||||
|
||||
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
|
||||
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
|
||||
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
|
||||
let mut pos = 0_usize;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let prefix = *descriptor.get(pos)?;
|
||||
pos += 1;
|
||||
if prefix == 0xFE {
|
||||
// Long item: a data length byte, a tag byte, then the data.
|
||||
let length = usize::from(*descriptor.get(pos)?);
|
||||
pos = pos.checked_add(2 + length)?;
|
||||
continue;
|
||||
}
|
||||
let size = match prefix & 0b11 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let data = descriptor.get(pos..pos + size)?;
|
||||
pos += size;
|
||||
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
|
||||
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Descriptor {
|
||||
multi_axis_controller: bool,
|
||||
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
|
||||
motion_report_axes: u8,
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
pub fn parse(descriptor: &[u8]) -> Self {
|
||||
let mut summary = Self::default();
|
||||
let mut page = 0_u32;
|
||||
let mut report_id = 0_u8;
|
||||
let mut depth = 0_u32;
|
||||
// Local items belong to the next main item only.
|
||||
let mut usages: Vec<u32> = Vec::new();
|
||||
let mut usage_min = None;
|
||||
for item in items(descriptor) {
|
||||
match (item.kind, item.tag) {
|
||||
// Global: usage page, report id.
|
||||
(1, 0) => page = item.value,
|
||||
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
|
||||
// Local: usage, usage minimum, usage maximum.
|
||||
(2, 0) => usages.push(Self::full_usage(page, item)),
|
||||
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
|
||||
(2, 2) => {
|
||||
if let Some(min) = usage_min.take() {
|
||||
let max = Self::full_usage(page, item);
|
||||
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
|
||||
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
|
||||
}
|
||||
}
|
||||
// Main: input, collection, end collection.
|
||||
(0, 8) => {
|
||||
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
|
||||
summary.note_axes(&usages);
|
||||
}
|
||||
}
|
||||
(0, 0xA) => {
|
||||
// An application collection (1) at the top level names the device.
|
||||
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
|
||||
summary.multi_axis_controller = true;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
(0, 0xC) => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if item.kind == 0 {
|
||||
usages.clear();
|
||||
usage_min = None;
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
|
||||
fn full_usage(page: u32, item: Item) -> u32 {
|
||||
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
|
||||
}
|
||||
|
||||
fn note_axes(&mut self, usages: &[u32]) {
|
||||
for usage in usages {
|
||||
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
|
||||
if axis < AXIS_COUNT {
|
||||
self.motion_report_axes |= 1 << axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
|
||||
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn is_multi_axis_controller(&self) -> bool {
|
||||
self.multi_axis_controller
|
||||
}
|
||||
|
||||
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
|
||||
pub fn layout(&self) -> Layout {
|
||||
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
|
||||
const SPLIT: &[u8] = &[
|
||||
0x05, 0x01, // usage page: generic desktop
|
||||
0x09, 0x08, // usage: multi-axis controller
|
||||
0xA1, 0x01, // collection: application
|
||||
0x85, 0x01, // report id 1
|
||||
0x16, 0xA2, 0xFE, // logical minimum -350
|
||||
0x26, 0x5E, 0x01, // logical maximum 350
|
||||
0x75, 0x10, // report size 16
|
||||
0x95, 0x03, // report count 3
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
|
||||
0x81, 0x06, // input: data, variable, relative
|
||||
0x85, 0x02, // report id 2
|
||||
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
|
||||
0x81, 0x06, // input
|
||||
0xC0, // end collection
|
||||
];
|
||||
|
||||
/// Report 1 = X Y Z Rx Ry Rz.
|
||||
const COMBINED: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
|
||||
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
|
||||
const COMBINED_RANGE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
|
||||
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
|
||||
const KEYBOARD: &[u8] =
|
||||
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
|
||||
|
||||
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
|
||||
const MOUSE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
|
||||
0x06, 0xC0, 0xC0,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
|
||||
let descriptor = Descriptor::parse(SPLIT);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0b000_111);
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_in_report_one_are_a_combined_layout() {
|
||||
for bytes in [COMBINED, COMBINED_RANGE] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Combined);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_interfaces_are_not_3d_mice() {
|
||||
for bytes in [KEYBOARD, MOUSE, &[][..]] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(!descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_byte_usages_carry_their_own_page() {
|
||||
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
|
||||
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_hostile_descriptors_never_panic() {
|
||||
for end in 0..=COMBINED.len() {
|
||||
let _ = Descriptor::parse(&COMBINED[..end]);
|
||||
}
|
||||
// Long item with a length running past the end, huge usage ranges, deep nesting.
|
||||
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
|
||||
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
|
||||
let _ = Descriptor::parse(&[0xC0; 64]);
|
||||
let _ = Descriptor::parse(&[0xA1; 64]);
|
||||
let mut state = 0x2545_F491_4F6C_DD1D_u64;
|
||||
for _ in 0..200 {
|
||||
let bytes: Vec<u8> = (0..64)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state.to_le_bytes()[0]
|
||||
})
|
||||
.collect();
|
||||
let _ = Descriptor::parse(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
|
||||
//! plain file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::hid_descriptor::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
}
|
||||
|
||||
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
|
||||
fn vendor_id(uevent: &str) -> Option<u16> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let vendor = id.split(':').nth(1)?;
|
||||
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
|
||||
pub fn find() -> Option<Device> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
|
||||
names.sort();
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
|
||||
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_vendor_from_the_hid_id() {
|
||||
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
|
||||
assert_eq!(vendor_id(uevent), Some(0x256f));
|
||||
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ids_are_rejected() {
|
||||
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
|
||||
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
@@ -1536,7 +1544,8 @@ pub struct Feature {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub suppressed: bool,
|
||||
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
|
||||
/// Names for the bodies this feature creates (in order); missing ones are numbered after the
|
||||
/// first (`Ribs (4)`), or get `BodyN` when there is none (or see `shell_piece_order`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub body_names: Vec<String>,
|
||||
/// Parameter names of the feature's inputs (see `FeatureKind::inputs`), e.g. `d3`.
|
||||
@@ -1559,6 +1568,13 @@ 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.
|
||||
/// Such features also name the bodies past their `body_names` `BodyN`, as those builds did,
|
||||
/// where newer ones number them after the first name (`Ribs (4)`, #373).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub shell_piece_order: bool,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
@@ -1893,6 +1909,7 @@ impl Document {
|
||||
edge_names: Vec::new(),
|
||||
face_names: Vec::new(),
|
||||
participants: Vec::new(),
|
||||
shell_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
match self.marker {
|
||||
@@ -2167,6 +2184,28 @@ impl Document {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
|
||||
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
|
||||
/// jump it reaches by stepping the changed values from there, and an edit that would turn
|
||||
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
|
||||
/// from the stored state again after a refused or failed first solve (the geometry is still
|
||||
/// the stored one), from the solved one otherwise.
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solvecraft_sketch::DrivingValues,
|
||||
) -> Result<solvecraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) };
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
|
||||
+596
-208
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)?;
|
||||
@@ -687,7 +687,7 @@ pub(super) fn sheet_cut(
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
|
||||
for bends in sh.unmodeled_solids(&ss.plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
|
||||
+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);
|
||||
|
||||
@@ -9,7 +9,13 @@ use crate::{DocError, Result};
|
||||
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
/// Boundary of the picked face as drawn: a closed surface cut into pieces (a cylinder or a
|
||||
/// round's torus in halves) projects as the whole patch, since its seams are hidden.
|
||||
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let patch = mesh.surface_patch(face as usize);
|
||||
if patch.len() > 1 {
|
||||
return family_edges(mesh, &patch.into_iter().collect(), plane);
|
||||
}
|
||||
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
+249
-15
@@ -879,10 +879,13 @@ impl SheetBody {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Actual curved material, used to certify unsupported folded machining before mutation.
|
||||
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
|
||||
/// Actual curved material, used to certify unsupported folded machining before mutation:
|
||||
/// one solid per bend zone or nonparallel panel. Each is sewn on its own, because the
|
||||
/// selected pieces need not form a manifold solid together (two bends meeting at an open
|
||||
/// corner relief touch only along an edge).
|
||||
pub(crate) fn unmodeled_solids(&self, plane: &Plane) -> Result<Vec<Body>> {
|
||||
if self.flat || self.flanges.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
let cells: Vec<_> = self
|
||||
@@ -893,10 +896,7 @@ impl SheetBody {
|
||||
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
|
||||
})
|
||||
.collect();
|
||||
if cells.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
|
||||
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
|
||||
}
|
||||
|
||||
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
@@ -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> {
|
||||
@@ -2049,4 +2250,37 @@ mod tests {
|
||||
s.pockets.push(pocket.clone());
|
||||
assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err());
|
||||
}
|
||||
|
||||
/// A sketch solved again after a parameter change leaves its corners off by ~1e-13 mm, so
|
||||
/// its edges are not quite square to the axes. Such a tray (four 72° flanges, shrunk to
|
||||
/// 400 x 300 from a larger size) failed to unfold in the planar arrangement while the same
|
||||
/// tray drawn at that size unfolded.
|
||||
#[test]
|
||||
fn nearly_square_base_unfolds_like_the_exact_one() {
|
||||
let rv = RuleValues { thickness: 2.0, k_factor: 0.5, bend_radius: 1.0, ..steel() };
|
||||
let tray = |[a, b, c, d]: [Vec2; 4]| {
|
||||
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, rect(a, b, c, d));
|
||||
let picks = [Vec3::new(200.0, 0.0, 2.0), Vec3::new(400.0, 150.0, 2.0), Vec3::new(200.0, 300.0, 2.0), Vec3::new(0.0, 150.0, 2.0)];
|
||||
s.add_flanges(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
s
|
||||
};
|
||||
let flat_area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(|r| r.area().abs()).sum::<f64>();
|
||||
let square = [Vec2::new(0.0, 0.0), Vec2::new(400.0, 0.0), Vec2::new(400.0, 300.0), Vec2::new(0.0, 300.0)];
|
||||
let exact = flat_area(&tray(square));
|
||||
// The corners the solver left after the parameter change, and the rectangle turned by a hair.
|
||||
let solved = [
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(400.0000000000001, 4.369543929613394e-13),
|
||||
Vec2::new(400.00000000000017, 300.0000000000008),
|
||||
Vec2::new(0.0, 300.0000000000009),
|
||||
];
|
||||
let (c, s) = (1e-14f64.cos(), 1e-14f64.sin());
|
||||
let turned = square.map(|p| Vec2::new(p.x * c - p.y * s, p.x * s + p.y * c));
|
||||
for corners in [solved, turned] {
|
||||
let mut sheet = tray(corners);
|
||||
assert!((flat_area(&sheet) - exact).abs() < 1e-6, "{} {exact}", flat_area(&sheet));
|
||||
sheet.flat = true;
|
||||
assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
@@ -312,6 +312,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
let before = s.doc.length_inputs();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().units = u.to_string();
|
||||
// Unit-less user parameters holding a bare number read it in the design's units where they
|
||||
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
|
||||
@@ -346,6 +347,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
@@ -445,13 +447,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());
|
||||
}
|
||||
|
||||
@@ -393,8 +393,9 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
|
||||
}
|
||||
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
|
||||
let solved = s.solved_sketches();
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,8 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "De
|
||||
/// Run another command's body inside this one (so the whole delete is one undo step).
|
||||
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
|
||||
let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(s, &p)?;
|
||||
let r = spec.run_nested(s, &p)?;
|
||||
s.refresh();
|
||||
Ok(r)
|
||||
}
|
||||
@@ -90,6 +89,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();
|
||||
|
||||
@@ -8,8 +8,8 @@ use crate::params::{bad, bool_, num, str_, string_list};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
|
||||
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||
.noundo()
|
||||
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||
@@ -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)
|
||||
@@ -51,28 +51,35 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let now = go_to(s, prev);
|
||||
let label = now.label.clone();
|
||||
s.redo.push(now);
|
||||
Ok(json!({"undone": label}))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let now = go_to(s, next);
|
||||
let label = now.label.clone();
|
||||
s.undo.push(now);
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
/// Put the session at `snap`; returns where it was, under the same label.
|
||||
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
|
||||
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
|
||||
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
|
||||
s.doc = snap.doc;
|
||||
s.active_sketch = snap.active_sketch;
|
||||
s.pending_moves = snap.pending_moves;
|
||||
s.selection.clear();
|
||||
// A new document is evaluated by `execute`.
|
||||
if same {
|
||||
s.refresh();
|
||||
}
|
||||
was
|
||||
}
|
||||
|
||||
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"checkpoint": s.checkpoint()}))
|
||||
}
|
||||
@@ -120,6 +127,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 +144,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 +166,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 +285,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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -459,8 +484,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
let r = spec.run_nested(&mut trial, ¶ms)?;
|
||||
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
|
||||
|
||||
@@ -185,3 +185,47 @@ fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence()
|
||||
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
|
||||
}
|
||||
}
|
||||
|
||||
/// #307: four pairs of holes touching at one line each (a ring pattern against a grid). Every
|
||||
/// contact is the same up to rounding, so every hole is one face: 33 holes and 6 planes.
|
||||
#[test]
|
||||
fn tangent_hole_contacts_split_no_faces() {
|
||||
let recipe: Value = serde_json::from_str(RECIPE_307).unwrap();
|
||||
let mut s = Session::default();
|
||||
for c in recipe["commands"].as_array().unwrap() {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let m = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
let body = &m["bodies"][0];
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 33, "plane": 6}), "{m}");
|
||||
assert_eq!(body["faces"], 39, "{m}");
|
||||
}
|
||||
|
||||
const RECIPE_307: &str = r#"{"commands": [{"command": "sketch.create", "params": {"name": "Plate_sketch", "plane": "XY"}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l1"], "points": [[-50, -50], [50, -50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l2"], "points": ["l1.end", [50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l3"], "points": ["l2.end", [-50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l4"], "points": ["l3.end", "l1.start"]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "distance": 5, "name": "Plate", "operation": "new", "profiles": [{"loops": [{"curves": ["l1", "l2", "l3", "l4"], "outer": true}], "sketch": "Plate_sketch"}], "sketch": "Plate_sketch"}}, {"command": "sketch.create", "params": {"name": "HoleA", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [-30, -30], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleA", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleA"}], "sketch": "HoleA", "through_all": true}}, {"command": "solid.pattern.rectangular", "params": {"count1": 5, "count2": 5, "dir1": [1, 0, 0], "dir2": [0, 1, 0], "features": ["HoleA"], "name": "GridPattern", "spacing1": 15, "spacing2": 15}}, {"command": "sketch.create", "params": {"name": "HoleB", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [25, 0], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleB", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleB"}], "sketch": "HoleB", "through_all": true}}, {"command": "solid.pattern.circular", "params": {"angle": 360, "axis": {"dir": [0, 0, 1], "origin": [0, 0, 0]}, "count": 8, "features": ["HoleB"], "name": "RingPattern"}}]}"#;
|
||||
|
||||
/// #308: a blind cut ending inside a cylinder leaves thin strips of the cylinder wall beside
|
||||
/// the cut; they must count as cylinder, not as a sphere or a freeform face.
|
||||
#[test]
|
||||
fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
|
||||
let mut s = Session::default();
|
||||
let side = json!({"origin":[0,0,0],"x_dir":[0,0,-1],"y_dir":[0,1,0]});
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":3,"body_name":"Hinge"})).unwrap();
|
||||
for (name, radius, operation, extent) in
|
||||
[("Knuckle", 4, "join", json!({"distance":40})), ("Pin", 2, "cut", json!({"distance":100,"direction":"symmetric"}))]
|
||||
{
|
||||
s.execute("sketch.create", &json!({"name":name,"plane":side})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center":[-4,33],"radius":radius})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let mut p = json!({"sketch":name,"operation":operation,"body_names":["Hinge"]});
|
||||
p.as_object_mut().unwrap().extend(extent.as_object().unwrap().clone());
|
||||
s.execute("solid.extrude", &p).unwrap();
|
||||
}
|
||||
s.execute("sketch.create", &json!({"name":"Gap","plane":"XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[12,29],"p1":[28,40]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"sketch":"Gap","operation":"cut","distance":3.3,"body_names":["Hinge"]})).unwrap();
|
||||
let m = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
let body = &m["bodies"][0];
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
|
||||
assert_eq!(body["faces"], 14, "{m}");
|
||||
}
|
||||
@@ -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,22 @@ 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`) has the rest numbered after the first by the evaluator: `Ribs`, `Ribs (2)`,
|
||||
// `Ribs (3)`… (#266, #373). They are stored on the feature, so recomputes and saved designs
|
||||
// keep them even when the profiles are later picked in another order.
|
||||
if names.first().is_some_and(|n| !n.trim().is_empty()) {
|
||||
let made: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
|
||||
if made.len() > names.len()
|
||||
&& let Some(f) = s.doc_mut().feature_mut(id)
|
||||
{
|
||||
f.body_names = made;
|
||||
}
|
||||
}
|
||||
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 +883,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 +999,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 +1037,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 +1076,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 +1511,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()) {
|
||||
|
||||
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -56,6 +57,10 @@ use serde_json::Value;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
/// A command's form for one frame of an interactive gesture: its result and the sketch it
|
||||
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
|
||||
/// evaluate as usual).
|
||||
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
|
||||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||||
|
||||
pub struct CommandSpec {
|
||||
@@ -73,11 +78,40 @@ pub struct CommandSpec {
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
pub undoable: bool,
|
||||
/// What runs instead of `run` when the command is given `live: true` (a frame of an
|
||||
/// interactive gesture, see [`Session::execute`]).
|
||||
pub live: Option<LiveRun>,
|
||||
/// Reads the evaluated model (the default). A command that reads only the document and the
|
||||
/// session's history runs on a model left stale by a live gesture without evaluating it first.
|
||||
pub reads_model: bool,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
|
||||
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
|
||||
CommandSpec {
|
||||
id,
|
||||
label,
|
||||
tab: "",
|
||||
panel: "",
|
||||
icon: "",
|
||||
shortcut: None,
|
||||
params: "",
|
||||
enabled: always,
|
||||
run,
|
||||
undoable: true,
|
||||
live: None,
|
||||
reads_model: true,
|
||||
}
|
||||
}
|
||||
/// Takes `live: true`, run by `run`.
|
||||
pub const fn live(mut self, run: LiveRun) -> Self {
|
||||
self.live = Some(run);
|
||||
self
|
||||
}
|
||||
/// Reads only the document (and history), never the evaluated model.
|
||||
pub const fn doc_only(mut self) -> Self {
|
||||
self.reads_model = false;
|
||||
self
|
||||
}
|
||||
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
|
||||
self.tab = tab;
|
||||
@@ -104,6 +138,12 @@ impl CommandSpec {
|
||||
self.undoable = false;
|
||||
self
|
||||
}
|
||||
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
|
||||
/// undo step and the evaluation).
|
||||
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
|
||||
(self.run)(s, p)
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
@@ -157,6 +197,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 +219,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()
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("joint")
|
||||
.params("occurrences: [ids or names] (two or more); name?"),
|
||||
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
|
||||
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
@@ -38,9 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", complete)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
|
||||
];
|
||||
|
||||
@@ -67,8 +69,10 @@ fn known(s: &Session, name: &str) -> bool {
|
||||
|
||||
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.change";
|
||||
let solved = s.solved_sketches();
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n.trim())?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
return Ok(json!({"deleted": n.trim()}));
|
||||
}
|
||||
let mut name = param_name(cmd, p)?.to_string();
|
||||
@@ -101,7 +105,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
out["name"] = json!(name);
|
||||
out["expression"] = json!(e);
|
||||
@@ -140,7 +144,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.add";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?;
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
@@ -160,7 +166,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = param_name("parameters.delete", p)?.to_string();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().remove_param(&name)?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
@@ -238,8 +246,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `text` or `path`")),
|
||||
};
|
||||
let solved = s.solved_sketches();
|
||||
let names = s.doc_mut().import_params(&text)?;
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"imported": names, "errors": failing(s)}))
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,11 @@ fn width_follows_an_expression() {
|
||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||
}
|
||||
|
||||
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
|
||||
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
|
||||
/// for "nothing changed". timeline.edit reports the same.
|
||||
/// Setting a value back reuses the earlier results and says so: the features it restored are
|
||||
/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A
|
||||
/// sketch dimension set back solves the sketch again from its current state (value edits
|
||||
/// continue from the last solved state); the features after it see the same geometry and are
|
||||
/// restored.
|
||||
#[test]
|
||||
fn setting_a_parameter_back_reports_restored_features() {
|
||||
let mut s = Session::default();
|
||||
@@ -77,7 +79,7 @@ fn setting_a_parameter_back_reports_restored_features() {
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
@@ -243,3 +245,302 @@ fn csv_and_json_round_trip() {
|
||||
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
|
||||
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
|
||||
/// Returns the session, the sketch feature and the nine dimensions' parameters.
|
||||
fn linkage_session() -> (Session, u64, Vec<String>) {
|
||||
let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]];
|
||||
let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
|
||||
let ids = ["a", "b", "c", "d", "e", "f"];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"}));
|
||||
for (id, at) in ids.iter().zip(layout) {
|
||||
run(&mut s, "sketch.point", json!({"point": at, "id": id}));
|
||||
}
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"}));
|
||||
let params = pairs
|
||||
.iter()
|
||||
.map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string())
|
||||
.collect();
|
||||
let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default();
|
||||
(s, feature, params)
|
||||
}
|
||||
|
||||
/// Edited values the iteration cannot reach in one jump from the layout.
|
||||
const REACHED_BY_CONTINUATION: [f64; 9] = [
|
||||
49.75798473660107,
|
||||
44.249179252138724,
|
||||
28.66332553713371,
|
||||
49.725624597008775,
|
||||
45.81086166277159,
|
||||
35.887660702567544,
|
||||
45.64700450204954,
|
||||
50.756441510188495,
|
||||
63.72063313963373,
|
||||
];
|
||||
|
||||
/// Turn of both triangles in the evaluated sketch.
|
||||
fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] {
|
||||
let state = s.model.state();
|
||||
let sk = &state.sketch(feature).unwrap().sketch;
|
||||
let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap();
|
||||
[(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0]
|
||||
}
|
||||
|
||||
/// Set the parameters in the document only (as an edit that does not store the sketch).
|
||||
fn set_params(s: &mut Session, params: &[String], values: &[f64]) {
|
||||
let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect();
|
||||
s.doc_mut().change_params_atomic(&changes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() {
|
||||
// The edit steps to the new values from the stored sketch, keeping both triangles' turn,
|
||||
// and stores what it reached: evaluating the document afresh needs no continuation.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = linkage_turns(&s, feature);
|
||||
// All nine at once: one at a time, some intermediate values have no solution.
|
||||
let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
|
||||
run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
|
||||
let solved = s.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(solved.ok(), "{solved:?}");
|
||||
assert_eq!(linkage_turns(&s, feature), before);
|
||||
let mut fresh = solvecraft_doc::Model::default();
|
||||
fresh.evaluate(&s.doc);
|
||||
let again = fresh.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(again.ok() && again.continuations == 0, "{again:?}");
|
||||
// The kept results are what evaluating the stored document gives.
|
||||
let (kept, afresh) = (s.model.state(), fresh.state());
|
||||
let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points);
|
||||
assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9));
|
||||
// In one go (one evaluation from the layout) it needs continuation.
|
||||
let (mut t, feature, params) = linkage_session();
|
||||
run(&mut t, "sketch.finish", json!({}));
|
||||
set_params(&mut t, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
t.refresh();
|
||||
let once = t.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(once.ok() && once.continuations > 0, "{once:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]}));
|
||||
let before = linkage_turns(&s, feature);
|
||||
set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]}));
|
||||
let solved = s.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(solved.ok(), "{solved:?}");
|
||||
assert_eq!(linkage_turns(&s, feature), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() {
|
||||
// Parameters no geometry satisfies, then an edit that does not insist on a solution: the
|
||||
// stored dimensions keep the values its geometry is solved for.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
let mut values = REACHED_BY_CONTINUATION;
|
||||
values[7] = 500.0;
|
||||
set_params(&mut s, ¶ms, &values);
|
||||
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
for c in stored.constraints.iter().filter(|c| c.param.is_some()) {
|
||||
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
|
||||
assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value());
|
||||
}
|
||||
}
|
||||
|
||||
/// Values the linkage reaches only with its top triangle mirrored.
|
||||
const FLIPPING: [f64; 9] = [
|
||||
26.483003374917082,
|
||||
90.0253808040292,
|
||||
84.67215428223084,
|
||||
54.580784880976275,
|
||||
68.12871394439186,
|
||||
30.98508537669166,
|
||||
116.94895853350752,
|
||||
103.75535113133466,
|
||||
32.83103173640168,
|
||||
];
|
||||
|
||||
/// All nine parameters set by one command.
|
||||
fn import_values(params: &[String], values: &[f64]) -> Value {
|
||||
let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
|
||||
json!({"text": serde_json::to_string(&batch).unwrap()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_would_flip_the_sketch_is_refused_and_named() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>());
|
||||
let why = err(&mut s, "parameters.import", import_values(¶ms, &FLIPPING));
|
||||
assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}");
|
||||
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_stored_with_other_values_still_rebuilds() {
|
||||
// A design whose stored sketch is not solved for its parameters (saved before value edits
|
||||
// stored their solve): evaluating it never refuses. The flipped solve is kept, with a
|
||||
// warning, the flipped constraints listed as conflicts, and the solid built from it.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] {
|
||||
run(&mut s, "sketch.line", json!({"points": line}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5}));
|
||||
set_params(&mut s, ¶ms, &FLIPPING);
|
||||
let s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let state = s.model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report);
|
||||
let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
// Listed as conflicts wherever conflicts are shown.
|
||||
let mut s = s;
|
||||
s.active_sketch = Some(feature);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
let glyphs = run(&mut s, "sketch.glyphs", json!({}));
|
||||
for id in &solved.report.folded {
|
||||
assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}");
|
||||
assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() {
|
||||
// `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving
|
||||
// the guide out of reach is the guide sketch's edit, not a value edit of the projecting
|
||||
// sketch: it applies, and the projecting sketch carries a warning.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap();
|
||||
let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().sketch(id).unwrap().report.ok());
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
assert_eq!(r["value"], json!(20.0), "the edit applies: {r}");
|
||||
assert!(!s.model.state().sketch(id).unwrap().report.ok());
|
||||
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() {
|
||||
// Auto-constrain works on the current values: reached by continuation, its result is
|
||||
// stored solved for them; values no geometry satisfies refuse it and change nothing.
|
||||
let consistent = |s: &Session, feature: u64| {
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| {
|
||||
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
|
||||
(measured - c.kind.value().unwrap()).abs() < 1e-6
|
||||
})
|
||||
};
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
let mut values = REACHED_BY_CONTINUATION;
|
||||
values[7] = 500.0;
|
||||
set_params(&mut s, ¶ms, &values);
|
||||
let before = s.doc.sketch(feature).unwrap().clone();
|
||||
let why = err(&mut s, "sketch.auto_constrain", json!({}));
|
||||
assert!(why.contains("conflict"), "{why}");
|
||||
assert_eq!(*s.doc.sketch(feature).unwrap(), before);
|
||||
assert!(consistent(&s, feature));
|
||||
set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
run(&mut s, "sketch.auto_constrain", json!({}));
|
||||
assert!(consistent(&s, feature));
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) {
|
||||
assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored sketch's points, by id.
|
||||
fn stored_points(s: &Session, feature: u64) -> Vec<solvecraft_geom::Vec2> {
|
||||
s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsolvable_value_edit_is_refused_and_changes_nothing() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (expr, points) = (s.doc.param(¶ms[7]).map(|p| p.expr.clone()), stored_points(&s, feature));
|
||||
let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"}));
|
||||
assert!(why.contains("Linkage") && why.contains("conflict"), "{why}");
|
||||
assert_eq!(s.doc.param(¶ms[7]).map(|p| p.expr.clone()), expr);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
assert!(s.model.state().sketch(feature).unwrap().report.ok());
|
||||
// A batch with one such value: none of it.
|
||||
let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect();
|
||||
let batch: Vec<_> = params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }}))
|
||||
.collect();
|
||||
let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
|
||||
assert!(why.contains("Linkage"), "{why}");
|
||||
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_edits_continue_from_the_last_solved_state() {
|
||||
// A → B → C, each a small edit solved in one jump; C is solved from B (the stored state),
|
||||
// and setting both values back reproduces A (the linkage is fully constrained).
|
||||
let close = |a: &[solvecraft_geom::Vec2], b: &[solvecraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol);
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let a = stored_points(&s, feature);
|
||||
let (p7, p8) = (s.doc.param(¶ms[7]).unwrap().expr.clone(), s.doc.param(¶ms[8]).unwrap().expr.clone());
|
||||
run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"}));
|
||||
let b_sketch = s.doc.sketch(feature).unwrap().clone();
|
||||
let b = stored_points(&s, feature);
|
||||
assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0);
|
||||
run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"}));
|
||||
let c = stored_points(&s, feature);
|
||||
// The same edit solved from B directly.
|
||||
let mut from_b = b_sketch.clone();
|
||||
let stored = from_b.driving_values();
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.apply_dimension_values(&vals, &mut from_b).unwrap();
|
||||
assert!(solvecraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok());
|
||||
assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::<Vec<_>>(), 1e-6) && !close(&c, &b, 1e-3));
|
||||
run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7}));
|
||||
run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8}));
|
||||
assert!(close(&stored_points(&s, feature), &a, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_configurations_back_reuses_the_later_features() {
|
||||
// Rows A and B of `len`: back on A, the sketch solves again from B's state to A's
|
||||
// geometry, and the extrude after it is restored from the cache.
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "config.configure", json!({"params": ["len"]}));
|
||||
run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}}));
|
||||
run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "B"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
|
||||
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
|
||||
let before = hits(&s);
|
||||
let r = run(&mut s, "config.activate", json!({"row": "A"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
assert!(hits(&s) > before, "{r}");
|
||||
}
|
||||
@@ -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,87 @@ 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:?}");
|
||||
}
|
||||
|
||||
/// A plate with `n` Ø2 circles drawn in one sketch (16 a row, 6 mm apart) and cut by one extrude:
|
||||
/// how long the cut took. It runs on its own thread so a hang fails the test instead of CI.
|
||||
fn sketch_hole_cut(n: usize) -> 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"}));
|
||||
for i in 0..n {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [4 + (i % 16) * 6, 4 + (i / 16) * 6], "diameter": 2}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let t = Instant::now();
|
||||
let r =
|
||||
s.execute("solid.extrude", &json!({"sketch": "H", "distance": "4", "direction": "negative", "operation": "cut", "targets": ["Plate"]}));
|
||||
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 cut did not finish within 900 s");
|
||||
let n = n as f64;
|
||||
let v = v.expect("cut");
|
||||
// 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}");
|
||||
took
|
||||
}
|
||||
|
||||
/// Many holes drawn in one sketch and cut by one extrude (#368), the usual way to draw a vent
|
||||
/// field: 32 took 2 s and 128 took 60 s, though a feature pattern of the same holes is near
|
||||
/// linear since #261. Linear is 4×. A ratio, not a time (see the 512-hole pattern test).
|
||||
#[test]
|
||||
#[ignore = "fails until #368 is fixed"]
|
||||
fn one_extrude_of_many_sketch_circles_scales_linearly() {
|
||||
let t32 = sketch_hole_cut(32);
|
||||
let t128 = sketch_hole_cut(128);
|
||||
assert!(t128.as_secs_f64() < 8.0 * t32.as_secs_f64() + 2.0, "32 circles took {t32:?}, 128 took {t128:?}");
|
||||
}
|
||||
@@ -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")
|
||||
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
|
||||
@@ -702,6 +702,45 @@ fn fold_keeps_the_larger_side_either_way() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
|
||||
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
|
||||
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
|
||||
/// failed", for W alone or both sizes, at 72° or 90°, while growing the tray worked.
|
||||
#[test]
|
||||
fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() {
|
||||
for angle in [72.0, 90.0] {
|
||||
let tray = |s: &mut Session, w: f64, h: f64| {
|
||||
run(s, "parameters.add", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
run(s, "parameters.add", json!({"name": "H", "expression": format!("{h} mm")}));
|
||||
run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Tray"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
|
||||
run(s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "H"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"type": "base", "sketch": "Tray"}));
|
||||
let edges = json!([[w / 2.0, 0, 2], [w, h / 2.0, 2], [w / 2.0, h, 2], [0, h / 2.0, 2]]);
|
||||
run(s, "sheet.flange", json!({"type": "edge", "edges": edges, "height": 38.6, "angle": angle}));
|
||||
run(s, "sheet.unfold", json!({}));
|
||||
};
|
||||
let mut direct = Session::default();
|
||||
tray(&mut direct, 400.0, 300.0);
|
||||
let want = flat(&mut direct)["flat_size_mm"].clone();
|
||||
let mut shrunk = Session::default();
|
||||
tray(&mut shrunk, 2000.0, 2500.0);
|
||||
for (name, expr) in [("W", "400 mm"), ("H", "300 mm")] {
|
||||
let r = run(&mut shrunk, "parameters.change", json!({"name": name, "expression": expr}));
|
||||
assert_eq!(r["errors"], json!([]), "{angle}° {name} = {expr}: {r}");
|
||||
}
|
||||
let got = flat(&mut shrunk)["flat_size_mm"].clone();
|
||||
for k in 0..3 {
|
||||
assert!((got[k].as_f64().unwrap() - want[k].as_f64().unwrap()).abs() < 1e-9, "{angle}°: {got} {want}");
|
||||
}
|
||||
assert!(rel(volume(&mut shrunk), volume(&mut direct)) < 1e-9, "{angle}°");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
|
||||
let mut s = Session::default();
|
||||
@@ -1558,3 +1597,159 @@ 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]}));
|
||||
}
|
||||
|
||||
/// #296: two bends meeting at an open corner relief touch only along an edge, so the folded
|
||||
/// material the machining guard checks must not be sewn into one solid.
|
||||
#[test]
|
||||
fn hole_beside_two_adjacent_edge_flanges_cuts_and_bends_stay_guarded() {
|
||||
let build = |s: &mut Session| {
|
||||
base(s, 80.0, 40.0);
|
||||
run(s, "sheet.flange", json!({"edges":[[40.0, 0.0, 2.5]],"height":30,"angle":90}));
|
||||
run(s, "sheet.flange", json!({"edges":[[80.0, 22.5, 2.5]],"height":15,"angle":90}));
|
||||
};
|
||||
let hole = |s: &mut Session, name: &str, centre: [f64; 2]| {
|
||||
run(s, "sketch.create", json!({"plane":"XY","name":name}));
|
||||
run(s, "sketch.circle.center", json!({"center":centre,"radius":3}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
s.execute("solid.extrude", &json!({"sketch":name,"through_all":true,"operation":"cut"}))
|
||||
};
|
||||
let mut s = Session::default();
|
||||
build(&mut s);
|
||||
let before = volume(&mut s);
|
||||
assert!((before - 14057.2).abs() < 0.1, "{before}");
|
||||
if let Err(e) = hole(&mut s, "Hole", [25.0, 20.0]) {
|
||||
panic!("hole beside two flanges: {e}");
|
||||
}
|
||||
let after = volume(&mut s);
|
||||
assert!((before - after - 9.0 * PI * 2.5).abs() < 0.05, "{before} -> {after}");
|
||||
let mut bend = Session::default();
|
||||
build(&mut bend);
|
||||
let error = hole(&mut bend, "Bend", [78.5, 20.0]).unwrap_err().to_string();
|
||||
assert!(error.contains("bend"), "{error}");
|
||||
}
|
||||
|
||||
/// A fold whose stored `fixed` point ends up off the sheet after a parameter change keeps working:
|
||||
/// the point still says which side stays (#370). Only a new pick off the sheet is refused (#263).
|
||||
#[test]
|
||||
#[ignore = "fails until #370 is fixed"]
|
||||
fn fold_keeps_working_when_a_parameter_moves_the_sheet_off_its_fixed_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "100 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Blank"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "w"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Blank"}));
|
||||
run(&mut s, "sheet.fold", json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 2.5], "name": "Fold1"}));
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "80 mm"}));
|
||||
let fold = run(&mut s, "timeline.inspect", json!({"feature": "Fold1"}));
|
||||
assert!(fold["error"].is_null(), "the fold fails after the width change: {}", fold["error"]);
|
||||
// The +x side (where the point is) stays flat out to the new width; the -x side stands up.
|
||||
let bb = &run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"];
|
||||
assert!((bb["max"][0].as_f64().unwrap_or(0.0) - 80.0).abs() < 1e-6, "{bb}");
|
||||
assert!(bb["max"][2].as_f64().unwrap_or(0.0) > 30.0, "{bb}");
|
||||
}
|
||||
|
||||
/// `fixed` 20 mm above a 2.5 mm sheet touches no material: refuse it or say so, like a point in a
|
||||
/// cut-out (#371).
|
||||
#[test]
|
||||
#[ignore = "fails until #371 is fixed"]
|
||||
fn fold_fixed_point_above_the_sheet_is_not_silently_accepted() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
match s.execute("sheet.fold", &json!({"points": [[40, 0, 2.5], [40, 60, 2.5]], "fixed": [90, 30, 20]})) {
|
||||
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.to_string().contains("fixed")));
|
||||
assert!(warned, "fixed [90, 30, 20] is 17.5 mm above the sheet, yet the fold succeeded silently: {r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+238
-116
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with};
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
|
||||
@@ -11,9 +11,12 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
#[path = "sketch_drag.rs"]
|
||||
mod drag;
|
||||
#[path = "sketch_face.rs"]
|
||||
pub(super) mod face;
|
||||
use dimensioned::draw as draw_dimensioned;
|
||||
use drag::drag_entity;
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
macro_rules! connect_doc {
|
||||
@@ -103,7 +106,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 points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. 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")
|
||||
@@ -170,8 +173,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve, point or constraint ids]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can",
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
|
||||
),
|
||||
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params(
|
||||
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
|
||||
),
|
||||
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
|
||||
.enabled(in_sketch)
|
||||
@@ -219,25 +225,143 @@ pub(super) fn edit<T>(
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<T>,
|
||||
) -> Result<(T, Value)> {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let (out, rep) = edit_solved(s, id, strict, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
|
||||
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
/// A solve's outcome as commands return it.
|
||||
pub(super) fn report_json(rep: &SolveReport) -> Value {
|
||||
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
|
||||
}
|
||||
|
||||
/// What an [`edit_solved`] step leaves for the edit's own solve.
|
||||
pub(super) enum Left {
|
||||
/// The sketch, solved from where it is.
|
||||
Unsolved,
|
||||
/// The sketch, solved keeping the orientation of these positions (the last solved ones,
|
||||
/// before a drag left a step placed but unsolved).
|
||||
MovedFrom(Vec<Vec2>),
|
||||
/// The sketch, solved with the dimensions' values (from the parameters) applied.
|
||||
Solved,
|
||||
}
|
||||
|
||||
impl Left {
|
||||
/// Solved again by the edit even when already solved (for its report).
|
||||
pub(super) fn solve_again(self) -> Left {
|
||||
match self {
|
||||
Left::Solved => Left::Unsolved,
|
||||
left => left,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `edit` of sketch `id` for a step that may solve the sketch itself: when it returns
|
||||
/// [`Left::Solved`] (and offsets need not be recomputed) the sketch is not solved again and the
|
||||
/// report is `None`.
|
||||
pub(super) fn edit_solved<T>(
|
||||
s: &mut Session,
|
||||
id: u64,
|
||||
strict: bool,
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(T, Left)>,
|
||||
) -> Result<(T, Option<SolveReport>)> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
super::sketch_project::refresh(s, &doc, id, &mut sk);
|
||||
let out = f(&mut sk, &mut doc)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, &mut sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
let mut rep = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
sk.refresh_offsets()?;
|
||||
rep = solve(&mut sk);
|
||||
}
|
||||
if strict && !rep.ok() {
|
||||
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
|
||||
}
|
||||
// The stored sketch is solved for its stored values.
|
||||
let stored = sk.driving_values();
|
||||
let (out, left) = f(&mut sk, &mut doc)?;
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
let rep = match left {
|
||||
// A drag step solved it (held, with the dimensions' values applied).
|
||||
Left::Solved if !offsets => None,
|
||||
// A drag left a step placed but unsolved: solve it keeping the orientation of the last
|
||||
// solved positions. A drag changes no values, so there is nothing to continue.
|
||||
Left::MovedFrom(reference) => {
|
||||
doc.apply_dimension_values(&doc.param_values().0, &mut sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
let unsolved = sk.clone();
|
||||
let mut rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
|
||||
if offsets {
|
||||
sk.refresh_offsets()?;
|
||||
rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
|
||||
}
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
// Solved for the document's values, continuing from the stored solved state.
|
||||
Left::Solved | Left::Unsolved => {
|
||||
let (vals, _) = doc.param_values();
|
||||
sk.refresh_offsets()?;
|
||||
let unsolved = sk.clone();
|
||||
#[cfg(test)]
|
||||
solve_counts::record(Analysis::Full);
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
};
|
||||
sk.validate_periodic_splines()?;
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
|
||||
Ok((out, rep))
|
||||
}
|
||||
|
||||
/// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an
|
||||
/// error; otherwise such a sketch is stored as the edit left it before the solve, its values
|
||||
/// and geometry together, so the stored sketch stays solved for its stored values.
|
||||
fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result<SolveReport> {
|
||||
if strict && !rep.keeps_orientation() {
|
||||
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
|
||||
}
|
||||
if !rep.keeps_orientation() {
|
||||
*sk = unsolved;
|
||||
}
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
|
||||
pub(super) fn counted_solve(
|
||||
sk: &mut Sketch,
|
||||
hold: &Hold,
|
||||
reference: Option<&[Vec2]>,
|
||||
analysis: Analysis,
|
||||
budget: Option<&mut Budget>,
|
||||
) -> SolveReport {
|
||||
#[cfg(test)]
|
||||
solve_counts::record(analysis);
|
||||
solve_with(sk, hold, reference, analysis, budget)
|
||||
}
|
||||
|
||||
/// Inside an `edit` closure: solve the sketch for the document's parameter values from its
|
||||
/// stored solved state, as the edit's own solve does, for commands that work on the current
|
||||
/// geometry.
|
||||
pub(super) fn solve_current(sk: &mut Sketch, doc: &solvecraft_doc::Document) -> Result<SolveReport> {
|
||||
let stored = sk.driving_values();
|
||||
let (values, _) = doc.param_values();
|
||||
Ok(doc.solve_sketch(&values, sk, &stored)?)
|
||||
}
|
||||
|
||||
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
|
||||
/// with a rank analysis).
|
||||
#[cfg(test)]
|
||||
pub(super) mod solve_counts {
|
||||
use std::cell::Cell;
|
||||
|
||||
use solvecraft_sketch::Analysis;
|
||||
|
||||
thread_local! {
|
||||
static COUNTS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
pub fn record(analysis: Analysis) {
|
||||
COUNTS.with(|c| {
|
||||
let (n, full) = c.get();
|
||||
c.set((n + 1, full + usize::from(analysis == Analysis::Full)));
|
||||
});
|
||||
}
|
||||
|
||||
/// The counts since the last call.
|
||||
pub fn take() -> (usize, usize) {
|
||||
COUNTS.with(|c| c.replace((0, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How close (mm) a new point must land to an existing one to be connected to it.
|
||||
@@ -1165,7 +1289,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
|
||||
driven: false,
|
||||
text: None,
|
||||
}));
|
||||
let ok = solve_holding(&mut trial, &h).ok();
|
||||
let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok();
|
||||
trial.constraints.retain(|c| c.id != TEMP);
|
||||
if ok && !collapsed(sk, &trial) {
|
||||
*sk = trial;
|
||||
@@ -1240,7 +1364,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
|
||||
{
|
||||
let rep = solve(&mut sk.clone());
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
|
||||
}
|
||||
constrain(s, p, cmd, |sk| {
|
||||
if let Some((a, b)) = line_points {
|
||||
@@ -1486,7 +1610,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
match auto_orientation(&ty, a, b, text_at) {
|
||||
"angle" => {
|
||||
let angle = (b - a).angle();
|
||||
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
|
||||
@@ -1523,7 +1647,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
((Some(a), None), Some((Some(b), None))) => {
|
||||
let (pa, pb) = (sk.point(a).unwrap_or_default(), sk.point(b).unwrap_or_default());
|
||||
match ty.as_str() {
|
||||
match auto_orientation(&ty, pa, pb, text_at) {
|
||||
"horizontal" => (ConstraintKind::DistanceX { p: a, q: b, value: (pb.x - pa.x).abs() }, (pb.x - pa.x).abs()),
|
||||
"vertical" => (ConstraintKind::DistanceY { p: a, q: b, value: (pb.y - pa.y).abs() }, (pb.y - pa.y).abs()),
|
||||
_ => (ConstraintKind::Distance { p: a, q: b, value: pa.dist(pb) }, pa.dist(pb)),
|
||||
@@ -1601,7 +1725,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();
|
||||
@@ -1643,6 +1767,28 @@ fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static st
|
||||
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
|
||||
}
|
||||
|
||||
/// The type of a dimension between `a` and `b` (two points or a line's ends). `auto` takes it
|
||||
/// from where the text is placed: across from the two (between the perpendiculars through
|
||||
/// them) the distance between them; past that, above or below them the horizontal distance,
|
||||
/// beside them the vertical one (#257). With no text, or when the two already line up with
|
||||
/// an axis, `auto` stays the distance between them; other types are returned unchanged.
|
||||
fn auto_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option<Vec2>) -> &str {
|
||||
let Some(t) = text_at.filter(|_| ty == "auto") else { return ty };
|
||||
let d = b - a;
|
||||
let len = d.len();
|
||||
let along = (t - a).dot(d) / len;
|
||||
let between = |v: f64, p: f64, q: f64| p.min(q) <= v && v <= p.max(q);
|
||||
if d.x.abs() <= 1e-9 * len || d.y.abs() <= 1e-9 * len || !along.is_finite() || (0.0..=len).contains(&along) {
|
||||
ty
|
||||
} else if between(t.x, a.x, b.x) {
|
||||
"horizontal"
|
||||
} else if between(t.y, a.y, b.y) {
|
||||
"vertical"
|
||||
} else {
|
||||
ty
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
|
||||
/// dimension so it follows the geometry.
|
||||
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
|
||||
@@ -1823,109 +1969,82 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move_point";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let (_, rep) = edit_solved(s, id, false, |sk, _| {
|
||||
let q = point_ref(sk, p.get("point"), cmd, "point")?;
|
||||
drag_entity(sk, Ent::Point(q), to, to);
|
||||
Ok(())
|
||||
Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again()))
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = p.get("from").and_then(vec2).unwrap_or(to);
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
drag_entity(sk, e, from, to);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
drag_frame(s, p, false).map(|(v, _)| v)
|
||||
}
|
||||
|
||||
/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and
|
||||
/// fully constrained geometry stays (the solve that follows pulls it back).
|
||||
fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
|
||||
let d = to - from;
|
||||
let mut hold = Hold::default();
|
||||
match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return;
|
||||
}
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = to;
|
||||
}
|
||||
hold.points.push(q);
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return;
|
||||
}
|
||||
let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return };
|
||||
let locked = sk.locked_points();
|
||||
let set = |sk: &mut Sketch, q: usize, at: Vec2| {
|
||||
if !locked.get(q).copied().unwrap_or(true)
|
||||
&& let Some(pt) = sk.points.get_mut(q)
|
||||
{
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, .. } => {
|
||||
let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0);
|
||||
if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
&& r > 1e-9
|
||||
{
|
||||
*slot = r;
|
||||
}
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let Some(o) = sk.point(cc) else { return };
|
||||
let r = o.dist(to);
|
||||
if r > 1e-9 {
|
||||
for q in [a, b] {
|
||||
if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) {
|
||||
set(sk, q, o + u * r);
|
||||
}
|
||||
}
|
||||
}
|
||||
hold.points.extend([cc, a, b]);
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, .. } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return };
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
if minor > 1e-9
|
||||
&& let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
{
|
||||
*slot = minor;
|
||||
}
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
|
||||
if let Some(at) = sk.point(q) {
|
||||
set(sk, q, at + d);
|
||||
}
|
||||
}
|
||||
hold.points.extend(k.point_ids());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn drag_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
|
||||
drag_frame(s, p, true)
|
||||
}
|
||||
|
||||
/// `sketch.drag`; `live`: one frame of an interactive drag, returned with the sketch to show.
|
||||
fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<crate::LiveEdit>)> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => to,
|
||||
Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?,
|
||||
};
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
|
||||
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
|
||||
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
// The held solves work on the dimensions' current values (a solved step applies them).
|
||||
doc.apply_dimension_values(&doc.param_values().0, sk)?;
|
||||
let (d, left) = drag_entity(sk, e, from, to, live);
|
||||
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
|
||||
// its degrees of freedom.
|
||||
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
|
||||
})?;
|
||||
// Where the grabbed spot went: `from` moved as the dragged points did on average (with the
|
||||
// whole step when it followed; otherwise as the sketch's own solve left them).
|
||||
let sk = s.doc.sketch(id)?;
|
||||
let moves: Vec<Vec2> = d.moving.iter().filter_map(|&(q, was)| Some(sk.point(q)? - was)).collect();
|
||||
let at = from + moves.iter().fold(Vec2::ZERO, |a, m| a + *m) * (1.0 / moves.len().max(1) as f64);
|
||||
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solvecraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
|
||||
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y]});
|
||||
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
}
|
||||
|
||||
/// Ends a live drag: the snap constraint it let go on, if any, then one evaluation of the model.
|
||||
/// It reads the model only before the sketch it edits (never stale), so `execute` does not
|
||||
/// evaluate first.
|
||||
fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag_end";
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let start = s.doc.clone();
|
||||
let mut constraint = Value::Null;
|
||||
if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) {
|
||||
let name = c.get("command").and_then(Value::as_str).unwrap_or_default();
|
||||
let spec = super::find_command(name)
|
||||
.filter(|spec| spec.id.starts_with("sketch.constraint."))
|
||||
.ok_or_else(|| bad(cmd, "`constraint.command` must be a sketch.constraint.* command"))?;
|
||||
let params = c.get("params").cloned().unwrap_or(Value::Null);
|
||||
// A constraint that cannot be added (it would over-constrain the sketch) is not kept.
|
||||
constraint = spec.run_nested(s, ¶ms).unwrap_or_else(|e| {
|
||||
s.doc = start.clone();
|
||||
json!({"error": e.to_string()})
|
||||
});
|
||||
}
|
||||
let mut trial = sk.clone();
|
||||
if solve_holding(&mut trial, &hold).ok() {
|
||||
*sk = trial;
|
||||
// A new constraint changed the document: `execute` evaluates the model after this.
|
||||
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
|
||||
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
|
||||
}
|
||||
if s.model.is_stale() {
|
||||
s.refresh();
|
||||
}
|
||||
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
|
||||
Ok(json!({"constraint": constraint, "sketch": report}))
|
||||
}
|
||||
|
||||
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
|
||||
@@ -2000,6 +2119,9 @@ mod rect_tests;
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_drag_live_tests.rs"]
|
||||
mod drag_live_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ids_tests.rs"]
|
||||
mod ids_tests;
|
||||
@@ -249,9 +249,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?,
|
||||
None => 0,
|
||||
};
|
||||
// Values of existing dimensions must be applied before judging degrees of freedom.
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, sk)?;
|
||||
// Values of existing dimensions must be applied (and reached from the stored solved
|
||||
// state) before judging degrees of freedom; a sketch they would turn over keeps its
|
||||
// stored geometry, as the edit's own solve then refuses them.
|
||||
let rep = super::sketch::solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|f| format!("the sketch constraints conflict ({f})"))));
|
||||
}
|
||||
Ok(run_auto(sk, doc, p, datum))
|
||||
})?;
|
||||
Ok(json!({"added": added, "dof": dof, "sketch": info}))
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Dragging sketch geometry without jumping to another solution.
|
||||
//!
|
||||
//! A constrained sketch usually has several solutions for one cursor position (a two-link arm's
|
||||
//! elbow up or down, a fillet inside or outside its corner). Each held solve of a drag is seeded
|
||||
//! on the positions from before it: the points the solver's construction places (from distances
|
||||
//! and alignments to placed points) keep the orientation they had there however far the step.
|
||||
//! The rest is left to the iteration, which from far away can land on any solution, so a long
|
||||
//! drag still walks from where the geometry is to where the cursor puts it in short steps, each
|
||||
//! solved from the previous step's result (continuation). Continuation is what keeps lengths the
|
||||
//! construction does not read (an Equal to another line, a rigid shape hanging off a held point);
|
||||
//! it also lets the arc sweep guard see every step, and lets a path that crosses where the
|
||||
//! geometry cannot reach stop short at the edge of that stretch and go on past it. It steps only
|
||||
//! when the move is long next to the dragged geometry, so a normal mouse frame is a single solve.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
|
||||
|
||||
use super::{Ent, Left, counted_solve};
|
||||
|
||||
/// Longest step, as a fraction of the smallest driving length or radius of the dragged group.
|
||||
/// Each step is seeded on its pre-step positions, so what the seed constructs keeps its branch
|
||||
/// whatever the step; the steps are for what it does not (an arc's sweep guard, paths across
|
||||
/// unreachable stretches). The drag tests first fail at 1 (an arc end wraps through its start):
|
||||
/// this keeps a factor of four. It holds where the seed placed what it could: once a held
|
||||
/// solve's decomposition runs out of budget (see [`Budget::exhausted`]), the rest of the drag
|
||||
/// takes `SHORT_STEP_FRACTION` steps, and continuation keeps the branch.
|
||||
const STEP_FRACTION: f64 = 0.25;
|
||||
/// The steps of a drag whose seed did not place everything it could (as before seeding steps).
|
||||
const SHORT_STEP_FRACTION: f64 = 0.125;
|
||||
/// Shortest step (mm), so a tiny feature cannot make a drag crawl.
|
||||
const MIN_STEP: f64 = 1e-3;
|
||||
/// Most steps one drag is split into.
|
||||
const MAX_STEPS: usize = 64;
|
||||
/// After a step fails, the way to it from the last solved state is halved until what is left is
|
||||
/// shorter than a step over this; at most `MAX_STEPS` such solves per drag.
|
||||
const HALVINGS: usize = 16;
|
||||
/// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start.
|
||||
const SWEEP_EPS: f64 = 1e-6;
|
||||
|
||||
/// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`).
|
||||
enum Path {
|
||||
/// Points slide by `d` (a dragged point by its way to the cursor).
|
||||
Slide { pts: Vec<(usize, Vec2)>, d: Vec2 },
|
||||
/// A circle's radius or an ellipse's minor radius follows the cursor.
|
||||
Radius { c: usize, from: f64, to: f64 },
|
||||
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
|
||||
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
|
||||
}
|
||||
|
||||
impl Path {
|
||||
fn length(&self) -> f64 {
|
||||
match self {
|
||||
Path::Slide { d, .. } => d.len(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The points the drag moves.
|
||||
fn points(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
let pts = match self {
|
||||
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
|
||||
Path::Radius { .. } => &[],
|
||||
};
|
||||
pts.iter().map(|(q, _)| *q)
|
||||
}
|
||||
|
||||
/// Put the dragged geometry where it is at `t` along the drag.
|
||||
fn apply(&self, sk: &mut Sketch, t: f64) {
|
||||
let mut set = |q: usize, at: Vec2| {
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Path::Slide { pts, d } => {
|
||||
for &(q, at) in pts {
|
||||
set(q, at + *d * t);
|
||||
}
|
||||
}
|
||||
Path::ArcRadius { o, ends, from, to } => {
|
||||
let r = from + (to - from) * t;
|
||||
for &(q, u) in ends {
|
||||
set(q, *o + u * r);
|
||||
}
|
||||
}
|
||||
Path::Radius { c, from, to } => {
|
||||
if let Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
|
||||
*r = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
|
||||
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
|
||||
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
|
||||
/// it, and the next steps are tried from the last solved state, the last of them being the whole
|
||||
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
|
||||
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
|
||||
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
|
||||
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
|
||||
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
|
||||
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
|
||||
///
|
||||
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
|
||||
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
|
||||
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
|
||||
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
|
||||
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
|
||||
};
|
||||
#[cfg(test)]
|
||||
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
|
||||
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
|
||||
let mut frame = live.then(frame_budget);
|
||||
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
|
||||
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
|
||||
// constraints pin down keeps the orientation it had there. A failed or wrapping step is
|
||||
// undone (a failed solve leaves the step placed). Also returns whether the decomposition ran
|
||||
// out of budget.
|
||||
let mut solves = Solves::default();
|
||||
let mut step = |sk: &mut Sketch, last: &Geometry, t: f64, solves: &mut Solves| {
|
||||
path.apply(sk, t);
|
||||
let mut own = Budget::full();
|
||||
let budget = frame.as_mut().unwrap_or(&mut own);
|
||||
let spent = budget.spent();
|
||||
let ok = counted_solve(sk, &hold, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok();
|
||||
solves.count += 1;
|
||||
solves.work += budget.spent() - spent;
|
||||
solves.cut |= budget.exhausted();
|
||||
let outcome = if !ok {
|
||||
Step::Failed
|
||||
} else if last.sweep_wrapped(sk) {
|
||||
Step::Wrapped
|
||||
} else {
|
||||
return Step::Solved;
|
||||
};
|
||||
last.restore(sk);
|
||||
outcome
|
||||
};
|
||||
let mut last = Geometry::default();
|
||||
last.take(sk);
|
||||
// How far along the last solved state is; the first step since it that failed.
|
||||
let (mut good, mut failed) = (0.0, None);
|
||||
let mut spare = MAX_STEPS;
|
||||
// The steps: `k` of `dt` from `base`; once a seed ran out of budget, the rest of the way in
|
||||
// short steps.
|
||||
let (mut base, mut dt, mut k, mut t) = (0.0, 1.0 / n as f64, 0, 0.0);
|
||||
let mut shortened = false;
|
||||
'walk: while t < 1.0 {
|
||||
if solves.cut && !shortened {
|
||||
shortened = true;
|
||||
let rest = 1.0 - t;
|
||||
let m = steps(rest * short as f64, 1.0);
|
||||
(base, dt, k) = (t, rest / m as f64, 0);
|
||||
}
|
||||
k += 1;
|
||||
t = base + dt * k as f64;
|
||||
// The last step ends exactly at the cursor.
|
||||
if 1.0 - t < 0.5 * dt {
|
||||
t = 1.0;
|
||||
}
|
||||
match step(sk, &last, t, &mut solves) {
|
||||
Step::Solved => {
|
||||
(good, failed) = (t, None);
|
||||
if t < 1.0 {
|
||||
last.take(sk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Step::Wrapped => {
|
||||
failed = None;
|
||||
break 'walk;
|
||||
}
|
||||
Step::Failed => {}
|
||||
}
|
||||
if failed.is_none() {
|
||||
// Halve the way to the failed step, so the state the next steps start from (and are
|
||||
// seeded on) is as close to it as the geometry follows. The shortest move is tried
|
||||
// first: geometry that cannot move at all (pinned) costs one solve, not every halving.
|
||||
let fine = dt / HALVINGS as f64;
|
||||
let mut miss = t;
|
||||
let mut first = true;
|
||||
while spare > 0 && miss - good > fine {
|
||||
spare -= 1;
|
||||
let mid = if std::mem::take(&mut first) { good + fine } else { 0.5 * (good + miss) };
|
||||
match step(sk, &last, mid, &mut solves) {
|
||||
Step::Solved => {
|
||||
good = mid;
|
||||
last.take(sk);
|
||||
}
|
||||
Step::Wrapped => break 'walk,
|
||||
Step::Failed => miss = mid,
|
||||
}
|
||||
}
|
||||
failed = Some(t);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
last_drag::set(solves);
|
||||
let left = match failed {
|
||||
// The failed step is left placed for the edit's solve, seeded on the last solved state.
|
||||
Some(t) => {
|
||||
path.apply(sk, t);
|
||||
Left::MovedFrom(last.pos)
|
||||
}
|
||||
None if good > 0.0 => Left::Solved,
|
||||
None => Left::Unsolved,
|
||||
};
|
||||
(Dragged { reached: good, moving }, left)
|
||||
}
|
||||
|
||||
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
|
||||
/// out of budget.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Solves {
|
||||
pub count: usize,
|
||||
pub work: usize,
|
||||
pub cut: bool,
|
||||
}
|
||||
|
||||
/// One live frame's decomposition budget (smaller in tests that set one).
|
||||
fn frame_budget() -> Budget {
|
||||
#[cfg(test)]
|
||||
if let Some(work) = last_drag::frame_work() {
|
||||
return Budget::new(work);
|
||||
}
|
||||
Budget::frame()
|
||||
}
|
||||
|
||||
/// What the last drag on this thread did, and a frame budget for tests to set.
|
||||
#[cfg(test)]
|
||||
pub(super) mod last_drag {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::Solves;
|
||||
|
||||
thread_local! {
|
||||
static LAST: Cell<Solves> = const { Cell::new(Solves { count: 0, work: 0, cut: false }) };
|
||||
static FRAME: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set(s: Solves) {
|
||||
LAST.with(|c| c.set(s));
|
||||
}
|
||||
|
||||
/// The held solves of the last drag.
|
||||
pub fn get() -> Solves {
|
||||
LAST.with(Cell::get)
|
||||
}
|
||||
|
||||
pub fn frame_work() -> Option<usize> {
|
||||
FRAME.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores the frame budget when dropped, also when the test panics.
|
||||
struct Restore(Option<usize>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
FRAME.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with live frames given `work` units of decomposition budget.
|
||||
pub fn with_frame_work<R>(work: usize, f: impl FnOnce() -> R) -> R {
|
||||
let _restore = Restore(FRAME.with(|c| c.replace(Some(work))));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// How one held solve of a drag went.
|
||||
enum Step {
|
||||
Solved,
|
||||
Failed,
|
||||
/// Solved, but it swept an arc through its own start.
|
||||
Wrapped,
|
||||
}
|
||||
|
||||
/// How far a drag got.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Dragged {
|
||||
/// The part of the way from where it was grabbed to the cursor the geometry followed (0..1).
|
||||
pub reached: f64,
|
||||
/// The points the drag moves (not the fixed ones it holds), where they were before it.
|
||||
pub moving: Vec<(usize, Vec2)>,
|
||||
}
|
||||
|
||||
/// The geometry a step changes (point positions; curves, for radii and the arc ends a solve
|
||||
/// may swap), kept to undo a step. Its buffers are reused from step to step.
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
pos: Vec<Vec2>,
|
||||
curves: Vec<(CurveKind, bool)>,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn take(&mut self, sk: &Sketch) {
|
||||
self.pos.clear();
|
||||
self.pos.extend(sk.points.iter().map(|p| p.pos));
|
||||
self.curves.clear();
|
||||
self.curves.extend(sk.curves.iter().map(|c| (c.kind.clone(), c.reversed)));
|
||||
}
|
||||
|
||||
fn restore(&self, sk: &mut Sketch) {
|
||||
for (p, at) in sk.points.iter_mut().zip(&self.pos) {
|
||||
p.pos = *at;
|
||||
}
|
||||
for (c, (kind, reversed)) in sk.curves.iter_mut().zip(&self.curves) {
|
||||
c.kind = kind.clone();
|
||||
c.reversed = *reversed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full
|
||||
/// turn, or ran from clear of them onto one? An arc already that close may still move.
|
||||
/// Measured on the arc's ends as they were before the step (a solve may swap them).
|
||||
fn sweep_wrapped(&self, sk: &Sketch) -> bool {
|
||||
let was = |q: usize| self.pos.get(q).copied();
|
||||
let now = |q: usize| sk.point(q);
|
||||
let clear = |s: f64| (SWEEP_EPS..=TAU - SWEEP_EPS).contains(&s);
|
||||
self.curves.iter().any(|(kind, _)| {
|
||||
let CurveKind::Arc { c, a, b } = *kind else { return false };
|
||||
let (Some(s0), Some(s1)) = (sweep(was(c), was(a), was(b)), sweep(now(c), now(a), now(b))) else { return false };
|
||||
(s1 - s0).abs() > PI || (clear(s0) && !clear(s1))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan
|
||||
/// `drag_entity` runs, for tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn drag_steps(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> usize {
|
||||
prepare(sk, e, from, to).map_or(0, |(_, _, n, _)| n)
|
||||
}
|
||||
|
||||
/// A drag's path, what its solves hold and its number of steps (and of short steps, see
|
||||
/// `SHORT_STEP_FRACTION`), or `None` if it cannot move.
|
||||
fn prepare(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold, usize, usize)> {
|
||||
if !(from.is_finite() && to.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (path, hold) = plan(sk, e, from, to)?;
|
||||
let n = step_count(sk, &hold, path.length(), STEP_FRACTION);
|
||||
let short = step_count(sk, &hold, path.length(), SHORT_STEP_FRACTION);
|
||||
Some((path, hold, n, short))
|
||||
}
|
||||
|
||||
/// A cap on the steps `drag_entity` takes on this thread, for tests that measure what fewer
|
||||
/// steps keep (the planned count, `drag_steps`, is not capped).
|
||||
#[cfg(test)]
|
||||
pub(super) mod step_cap {
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static CAP: Cell<usize> = const { Cell::new(super::MAX_STEPS) };
|
||||
}
|
||||
|
||||
pub fn get() -> usize {
|
||||
CAP.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores the cap when dropped, also when the test panics.
|
||||
struct Restore(usize);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
CAP.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with drags split into at most `cap` steps (at least one).
|
||||
pub fn with<R>(cap: usize, f: impl FnOnce() -> R) -> R {
|
||||
let _restore = Restore(CAP.with(|c| c.replace(cap.max(1))));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps of at most `step` covering `length`: at least one, at most `MAX_STEPS`.
|
||||
fn steps(length: f64, step: f64) -> usize {
|
||||
let n = (length / step).ceil();
|
||||
if n.is_finite() && n >= 1.0 { (n as usize).min(MAX_STEPS) } else { 1 }
|
||||
}
|
||||
|
||||
/// The dragged geometry's path and what the solves hold, or `None` if it cannot move.
|
||||
fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
let mut hold = Hold::default();
|
||||
let path = match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return None;
|
||||
}
|
||||
hold.points.push(q);
|
||||
let at = sk.point(q)?;
|
||||
Path::Slide { pts: vec![(q, at)], d: to - at }
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return None;
|
||||
}
|
||||
let kind = sk.curves.get(c).map(|x| x.kind.clone())?;
|
||||
let locked = sk.locked_points();
|
||||
let free = |q: &usize| !locked.get(*q).copied().unwrap_or(true);
|
||||
// A cursor on the centre leaves the radius as it is.
|
||||
let radius_to = |o: Vec2, r: f64| Some(o.dist(to)).filter(|x| *x > 1e-9).unwrap_or(r);
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, r } => {
|
||||
let to_r = sk.point(cc).map_or(r, |o| radius_to(o, r));
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: to_r }
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let o = sk.point(cc)?;
|
||||
let r = sk.radius(c).unwrap_or(0.0);
|
||||
hold.points.extend([cc, a, b]);
|
||||
let ends = [a, b].into_iter().filter(free).filter_map(|q| Some((q, (sk.point(q)? - o).normalized()?))).collect();
|
||||
Path::ArcRadius { o, ends, from: r, to: radius_to(o, r) }
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (o, pm) = (sk.point(cc)?, sk.point(m)?);
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
let mut ids = k.point_ids();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect();
|
||||
hold.points.extend(ids);
|
||||
Path::Slide { pts, d: to - from }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((path, hold))
|
||||
}
|
||||
|
||||
/// Steps for a move of `length`: enough that none is longer than `fraction` of the smallest
|
||||
/// size of the geometry the solve moves with what it holds (at least `MIN_STEP`), at most
|
||||
/// `MAX_STEPS`. A move short next to every size in the sketch (a normal mouse frame) is one
|
||||
/// step, found without grouping the sketch.
|
||||
fn step_count(sk: &Sketch, hold: &Hold, length: f64, fraction: f64) -> usize {
|
||||
let step = |size: f64| (size * fraction).max(MIN_STEP);
|
||||
if length <= step(smallest_size(sk, hold, None)) {
|
||||
return 1;
|
||||
}
|
||||
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
|
||||
steps(length, step(smallest_size(sk, hold, Some(Group { points: &points, radii: &radii }))))
|
||||
}
|
||||
|
||||
/// What a solve moves together: per point and per curve radius.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Group<'a> {
|
||||
points: &'a [bool],
|
||||
radii: &'a [bool],
|
||||
}
|
||||
|
||||
impl Group<'_> {
|
||||
/// Does an entity with these points and curves belong to the group?
|
||||
fn has(&self, points: &[usize], curves: &[usize]) -> bool {
|
||||
let any = |flags: &[bool], ids: &[usize]| ids.iter().any(|i| flags.get(*i).copied().unwrap_or(false));
|
||||
any(self.points, points) || any(self.radii, curves)
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest size that can make a constrained shape take another solution (of the geometry
|
||||
/// in `group`, or of the whole sketch), or infinity when there is none: every length the
|
||||
/// constraints hold. That is driving length dimensions (offsets along an axis or from a line
|
||||
/// too: the solver holds their size, either side), radii and the current lengths of lines held
|
||||
/// equal to another (a link's length can come from an Equal as well as a dimension). A line that
|
||||
/// only keeps a direction (a rectangle's side) holds no length: it may pass through zero and turn
|
||||
/// over, so it does not shrink the steps. Nor does a distance between two held points (the
|
||||
/// dragged line's own length): held geometry moves as one. A circle's radius counts when the
|
||||
/// solve moves the radius itself (not merely its centre).
|
||||
fn smallest_size(sk: &Sketch, hold: &Hold, group: Option<Group>) -> f64 {
|
||||
// Point lists are built only to test a size smaller than the smallest so far against the
|
||||
// group; without a group nothing is built.
|
||||
let in_group = |points: &dyn Fn() -> Vec<usize>, curves: &[usize]| group.is_none_or(|g| g.has(&points(), curves));
|
||||
// A radius is the circle's own variable, or (an arc's) the distance between its points.
|
||||
let radius_in_group = |c: usize| match sk.curves.get(c).map(|x| &x.kind) {
|
||||
Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) => in_group(&Vec::new, &[c]),
|
||||
Some(k) => in_group(&|| k.point_ids(), &[]),
|
||||
None => false,
|
||||
};
|
||||
let line_length = |l: usize| super::line_pts(sk, l).map(|(a, b)| a.dist(b));
|
||||
let points = |k: &ConstraintKind| sk.constraint_points(k);
|
||||
let mut min = f64::INFINITY;
|
||||
let mut take = |size: f64, counts: &dyn Fn() -> bool| {
|
||||
let size = size.abs();
|
||||
if size.is_finite() && size > 1e-9 && size < min && counts() {
|
||||
min = size;
|
||||
}
|
||||
};
|
||||
for k in sk.constraints.iter().filter(|k| !k.driven).map(|k| &k.kind) {
|
||||
match *k {
|
||||
ConstraintKind::Equal { a, b } => {
|
||||
if let Some(v) = line_length(a).into_iter().chain(line_length(b)).reduce(f64::min) {
|
||||
take(v, &|| in_group(&|| points(k), &[]));
|
||||
}
|
||||
}
|
||||
ConstraintKind::Radius { c, value } => take(value, &|| radius_in_group(c)),
|
||||
ConstraintKind::Diameter { c, value } => take(value / 2.0, &|| radius_in_group(c)),
|
||||
_ => {
|
||||
if let Some(v) = driving_length(k) {
|
||||
// Held points move as one: a length between them never changes.
|
||||
take(v, &|| {
|
||||
let pts = points(k);
|
||||
!pts.iter().all(|p| hold.points.contains(p)) && in_group(&|| pts.clone(), &k.indices().1)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..sk.curves.len() {
|
||||
if let Some(r) = sk.radius(c) {
|
||||
take(r, &|| radius_in_group(c));
|
||||
}
|
||||
}
|
||||
min
|
||||
}
|
||||
|
||||
/// The length a driving dimension sets (a linear diameter's radius), if it sets one.
|
||||
fn driving_length(k: &ConstraintKind) -> Option<f64> {
|
||||
if k.is_angle() {
|
||||
return None;
|
||||
}
|
||||
let v = k.value()?;
|
||||
Some(if matches!(k, ConstraintKind::LinearDiameter { .. }) { v / 2.0 } else { v })
|
||||
}
|
||||
|
||||
/// Counter-clockwise sweep (radians, `[0, 2π)`) from `a` to `b` about `c`.
|
||||
fn sweep(c: Option<Vec2>, a: Option<Vec2>, b: Option<Vec2>) -> Option<f64> {
|
||||
let (o, pa, pb) = (c?, a?, b?);
|
||||
Some(((pb - o).angle() - (pa - o).angle()).rem_euclid(TAU))
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Live drags (`sketch.drag` with `live: true`): each frame solves the sketch once, without a
|
||||
//! rank analysis, and evaluates nothing after it; the gesture's end evaluates the model once.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::solve_counts;
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimensioned 20 x 15 rectangle from (5, 5) extruded 5 mm, its sketch being edited again;
|
||||
/// returns the session and the extrude's id.
|
||||
fn extruded() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let extrude = s.doc.features.last().map(|f| f.id).expect("the extrude");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
(s, extrude)
|
||||
}
|
||||
|
||||
/// Counters from here on.
|
||||
fn reset(s: &mut Session) {
|
||||
s.evaluations = 0;
|
||||
solve_counts::take();
|
||||
}
|
||||
|
||||
/// Frames moving the rectangle's corner (5, 5) (and so the whole rectangle) left by 1 mm each.
|
||||
fn frames(s: &mut Session, n: usize, live: bool) -> Vec<Value> {
|
||||
(1..=n)
|
||||
.map(|k| {
|
||||
let (from, to) = ([6.0 - k as f64, 5.0], [5.0 - k as f64, 5.0]);
|
||||
run(s, "sketch.drag", json!({"entity": "l1.start", "from": from, "to": to, "live": live}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body_contains(s: &Session, p: Vec3) -> bool {
|
||||
s.model.state().bodies.first().expect("a body").body.contains(p)
|
||||
}
|
||||
|
||||
fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 {
|
||||
let sk = s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).expect("sketch mode");
|
||||
sk.resolve_point(id).and_then(|i| sk.point(i)).expect("the point")
|
||||
}
|
||||
|
||||
fn corner(s: &Session) -> solvecraft_geom::Vec2 {
|
||||
let id = s.active_sketch.expect("sketch mode");
|
||||
let st = s.world_state();
|
||||
let sk = &st.sketch(id).expect("the shown sketch").sketch;
|
||||
sk.resolve_point("l1.start").and_then(|i| sk.point(i)).expect("the corner")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_solves_once_and_leaves_the_features_after_the_sketch_stale() {
|
||||
let (mut s, extrude) = extruded();
|
||||
let body = s.model.state().bodies.first().expect("a body").body.clone();
|
||||
reset(&mut s);
|
||||
let r = frames(&mut s, 1, true).remove(0);
|
||||
assert_eq!(s.evaluations, 0, "a live frame evaluates no feature");
|
||||
assert_eq!(solve_counts::take(), (1, 0), "one held solve, no rank analysis");
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true));
|
||||
assert_eq!(r["sketch"]["dof"], json!(2), "the degrees of freedom from before the drag");
|
||||
// The sketch shows its new place; the extrude keeps its last result.
|
||||
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(4.0, 5.0)) < 1e-9);
|
||||
let result = s.model.result(extrude).expect("the extrude's result");
|
||||
assert!(result.stale);
|
||||
assert!(s.model.state().bodies.first().expect("a body").body.same(&body), "the body is not rebuilt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_end_evaluates_once_to_the_scripted_drags_result() {
|
||||
let (mut live, extrude) = extruded();
|
||||
let (mut scripted, _) = extruded();
|
||||
frames(&mut live, 3, true);
|
||||
reset(&mut live);
|
||||
let end = run(&mut live, "sketch.drag_end", json!({}));
|
||||
assert_eq!(live.evaluations, 1, "one full evaluation at the end");
|
||||
assert_eq!(solve_counts::take(), (0, 0));
|
||||
assert_eq!(end["sketch"], json!({"solved": true, "dof": 2}));
|
||||
assert!(!live.model.is_stale());
|
||||
frames(&mut scripted, 3, false);
|
||||
assert_eq!(*live.doc, *scripted.doc, "the same design as dragging without `live`");
|
||||
let (a, b) = (live.model.state(), scripted.model.state());
|
||||
let (sa, sb) = (&a.sketches[0], &b.sketches[0]);
|
||||
assert_eq!(sa.sketch, sb.sketch);
|
||||
assert_eq!((sa.report.dof, &sa.report.point_determined), (sb.report.dof, &sb.report.point_determined));
|
||||
assert!(body_contains(&live, Vec3::new(2.5, 6.0, 2.5)) && !body_contains(&live, Vec3::new(22.5, 6.0, 2.5)), "the body followed");
|
||||
assert!(live.model.result(extrude).is_some_and(|r| !r.stale && r.error.is_none()));
|
||||
// One undo step for the gesture is the UI's business; each frame is still undoable.
|
||||
run(&mut live, "edit.undo", json!({}));
|
||||
assert!(corner(&live).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_live_drag_is_evaluated_before_the_next_command() {
|
||||
let (mut s, extrude) = extruded();
|
||||
frames(&mut s, 2, true);
|
||||
assert!(s.model.is_stale());
|
||||
reset(&mut s);
|
||||
// A command that changes nothing still sees (and leaves) a fully evaluated model.
|
||||
run(&mut s, "sketch.solve", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
|
||||
assert!(body_contains(&s, Vec3::new(3.5, 6.0, 2.5)));
|
||||
// Leaving the sketch does too.
|
||||
frames(&mut s, 1, true);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(!s.model.is_stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scripted_drag_still_evaluates_the_whole_model() {
|
||||
let (mut s, extrude) = extruded();
|
||||
reset(&mut s);
|
||||
let r = frames(&mut s, 1, false).remove(0);
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert_eq!(solve_counts::take(), (2, 1), "the held solve, then the sketch's own solve with its analysis");
|
||||
assert_eq!(r["sketch"], json!({"solved": true, "dof": 2}));
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
|
||||
assert!(body_contains(&s, Vec3::new(4.5, 6.0, 2.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_reports_how_far_the_geometry_followed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 30], [40, 30]]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
for live in [false, true] {
|
||||
// A line of fixed length with a fixed end cannot slide: the sketch's own solve only
|
||||
// turns it a little toward the cursor, and the grab follows its free end.
|
||||
let before = pt(&s, "l1.end");
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l1", "from": [25, 10], "to": [25, 20], "live": live}));
|
||||
assert_eq!(r["reached"], json!(0.0), "{r}");
|
||||
let moved = pt(&s, "l1.end") - before;
|
||||
let at = crate::params::vec2(&r["at"]).expect("`at`");
|
||||
assert!(at.dist(solvecraft_geom::Vec2::new(25.0, 10.0) + moved) < 1e-9 && at.y < 20.0, "{r}");
|
||||
let to = 40 + 10 * usize::from(live);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2", "from": [25, to - 10], "to": [25, to]}));
|
||||
assert_eq!((&r["reached"], &r["at"]), (&json!(1.0), &json!([25.0, to as f64])), "{r}");
|
||||
}
|
||||
// A point that cannot move at all.
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [0, 0], "live": true}));
|
||||
assert_eq!(r["reached"], json!(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_drag_of_a_lone_sketch_shows_it_without_evaluating() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
reset(&mut s);
|
||||
frames(&mut s, 2, true);
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert_eq!(solve_counts::take(), (2, 0));
|
||||
// The free rectangle's corner moves alone; its profile follows.
|
||||
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
|
||||
let st = s.model.state();
|
||||
let profile = st.sketches[0].profiles.first().expect("a profile");
|
||||
assert!((profile.area - 22.0 * 15.0).abs() < 1e-9, "{}", profile.area);
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
}
|
||||
|
||||
/// Frames of a UI drag of `entity` grabbed at `grab`, the cursor moving by `moves` in turn: each
|
||||
/// frame's `from` is the previous frame's `at`, as the viewport does.
|
||||
fn ui_frames(s: &mut Session, entity: &str, grab: [f64; 2], moves: &[[f64; 2]]) {
|
||||
let (mut from, mut cursor) = (grab, grab);
|
||||
for m in moves {
|
||||
cursor = [cursor[0] + m[0], cursor[1] + m[1]];
|
||||
let r = run(s, "sketch.drag", json!({"entity": entity, "from": from, "to": cursor, "live": true}));
|
||||
let at = crate::params::vec2(&r["at"]).expect("`at`");
|
||||
from = [at.x, at.y];
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grab_follows_the_points_that_move() {
|
||||
// A line with a fixed end, dragged by its middle: only the free end moves, and it keeps the
|
||||
// cursor's offset frame after frame.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [30, 10]]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
ui_frames(&mut s, "l1", [20.0, 10.0], &[[0.0, 1.0]; 6]);
|
||||
assert!(pt(&s, "l1.end").dist(solvecraft_geom::Vec2::new(30.0, 16.0)) < 1e-9, "{:?}", pt(&s, "l1.end"));
|
||||
assert!(pt(&s, "l1.start").dist(solvecraft_geom::Vec2::new(10.0, 10.0)) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_during_a_live_drag_does_not_evaluate_the_model() {
|
||||
let (mut s, _) = extruded();
|
||||
frames(&mut s, 1, true);
|
||||
reset(&mut s);
|
||||
for (id, p) in
|
||||
[("parameters.complete", json!({"prefix": "d"})), ("parameters.list", json!({})), ("expr.evaluate", json!({"expression": "d1 * 2"}))]
|
||||
{
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert!(s.model.is_stale(), "the drag goes on");
|
||||
frames(&mut s, 1, true);
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
|
||||
fn sketch_of(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).cloned().expect("sketch mode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_solves_the_dimensions_current_values() {
|
||||
let (mut s, _) = extruded();
|
||||
// The bottom side's parameter now says 25 while the sketch still holds 20 mm.
|
||||
let sk = sketch_of(&s);
|
||||
let l1 = sk.curve_index("l1").expect("l1");
|
||||
let param = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.find(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Length { l, .. } if l == l1))
|
||||
.and_then(|c| c.param.clone())
|
||||
.expect("l1's dimension");
|
||||
if let Some(p) = s.doc_mut().params.iter_mut().find(|p| p.name == param) {
|
||||
p.expr = "25".into();
|
||||
}
|
||||
let r = frames(&mut s, 1, true).remove(0);
|
||||
assert_eq!(r["sketch"]["solved"], json!(true));
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!((a.dist(b) - 25.0).abs() < 1e-6, "l1 is {} mm", a.dist(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_swaps_only_the_dragged_sketch() {
|
||||
let (mut s, _) = extruded();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
let id = s.active_sketch.expect("sketch mode");
|
||||
let other = |s: &Session| s.model.state().sketches.iter().find(|x| x.feature != id).cloned().expect("the other sketch");
|
||||
let before = other(&s);
|
||||
frames(&mut s, 1, true);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &other(&s)), "the other sketch is shared, not copied");
|
||||
// Later frames change the state they own in place.
|
||||
let state = std::sync::Arc::as_ptr(&s.model.state());
|
||||
frames(&mut s, 1, true);
|
||||
assert_eq!(state, std::sync::Arc::as_ptr(&s.model.state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letting_go_on_a_snap_or_undoing_evaluates_once() {
|
||||
let (mut s, _) = extruded();
|
||||
run(&mut s, "sketch.point", json!({"point": [-10, 5], "connect": false}));
|
||||
let point = sketch_of(&s).points.last().map(|p| p.id.clone()).expect("the point");
|
||||
frames(&mut s, 3, true);
|
||||
reset(&mut s);
|
||||
let snap = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": point}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": snap }));
|
||||
assert_eq!(s.evaluations, 1, "{r}");
|
||||
assert!(r["constraint"].get("error").is_none(), "{r}");
|
||||
assert!(!s.model.is_stale());
|
||||
// Undo after an abandoned drag.
|
||||
frames(&mut s, 1, true);
|
||||
reset(&mut s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert!(!s.model.is_stale());
|
||||
// A snap that over-constrains the sketch is not kept; the drag still ends.
|
||||
frames(&mut s, 1, true);
|
||||
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": "l1.end"}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": bad }));
|
||||
assert!(r["constraint"]["error"].is_string(), "{r}");
|
||||
assert!(!s.model.is_stale());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use solvecraft_doc::ProfileSel;
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
|
||||
|
||||
use super::sketch::{add_c, edit, ids_of};
|
||||
use super::sketch::{add_c, edit, ids_of, solve_current};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::{Result, Session};
|
||||
@@ -378,10 +378,9 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.extend";
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
|
||||
}
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
@@ -453,7 +452,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pin(sk, end, oi)?;
|
||||
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
|
||||
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
|
||||
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
|
||||
if !solvecraft_sketch::solve_with(sk, &hold, None, solvecraft_sketch::Analysis::Full, None).ok()
|
||||
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|
||||
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
|
||||
{
|
||||
@@ -928,11 +927,10 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let side = p.get("side").and_then(vec2);
|
||||
let use_chain = bool_(p, "chain").unwrap_or(true);
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve existing constraints before offsetting"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
|
||||
@@ -1137,14 +1135,9 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A parameter edit may have changed the evaluated source without
|
||||
// rewriting the stored fit points. Break must freeze the displayed,
|
||||
// current dimensioned geometry, rather than its stale stored predecessor.
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
if let Some(association) = sk
|
||||
|
||||
@@ -499,3 +499,30 @@ fn project_and_include_from_other_bodies() {
|
||||
assert!(row["error"].is_null(), "{row}");
|
||||
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
|
||||
}
|
||||
|
||||
/// A cylinder's wall and the torus of a rounded rim are drawn as one face but stored in halves
|
||||
/// joined by hidden seams; projecting one projects the whole face, not an open half.
|
||||
#[test]
|
||||
fn faces_split_by_hidden_seams_project_whole() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XZ"}));
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"face":[20,0,5]}]}));
|
||||
let wall = run(&mut s, "sketch.inspect", json!({}));
|
||||
let heights: Vec<f64> =
|
||||
wall["curves"].as_array().unwrap().iter().flat_map(|c| [c["start_at"][1].as_f64().unwrap(), c["end_at"][1].as_f64().unwrap()]).collect();
|
||||
assert!(heights.iter().any(|y| y.abs() < 1e-6) && heights.iter().any(|y| (y.abs() - 10.).abs() < 1e-6), "{wall}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.fillet", json!({"edges":[[20,0,10]],"radius":3}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
let t = 0.5f64.sqrt() * 3.;
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"face":[17. + t, 0, 7. + t]}]}));
|
||||
let round = run(&mut s, "sketch.inspect", json!({}));
|
||||
let mut radii: Vec<f64> = round["curves"].as_array().unwrap().iter().map(|c| c["radius"].as_f64().unwrap()).collect();
|
||||
radii.sort_by(f64::total_cmp);
|
||||
assert_eq!(radii.len(), 4, "{round}");
|
||||
assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}");
|
||||
}
|
||||
@@ -552,6 +552,584 @@ fn constrained_geometry_resists_drags() {
|
||||
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// How the arm's link lengths are set.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Links {
|
||||
/// Length dimensions on both links.
|
||||
Dimensioned,
|
||||
/// Equal to fixed reference lines (no length dimension anywhere).
|
||||
EqualToFixed,
|
||||
}
|
||||
|
||||
/// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged
|
||||
/// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The
|
||||
/// elbow has two solutions for every hand position; a drag must keep the one it started on.
|
||||
/// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short).
|
||||
fn arm_drags(links: Links) -> (usize, usize) {
|
||||
use std::f64::consts::TAU;
|
||||
let (a, b) = (30.0, 20.0);
|
||||
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
|
||||
let mut s = new_sketch();
|
||||
// Away from the sketch origin so nothing connects to it.
|
||||
let o = v(200.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
match links {
|
||||
Links::Dimensioned => {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
|
||||
}
|
||||
Links::EqualToFixed => {
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]}));
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"}));
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"}));
|
||||
}
|
||||
}
|
||||
let id = s.active_sketch.expect("sketch");
|
||||
let sk0 = sketch(&s);
|
||||
assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned));
|
||||
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
|
||||
let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end"));
|
||||
let p2b = ix("l2.start");
|
||||
let side = |sk: &Sketch| {
|
||||
let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos);
|
||||
(x3 - x1).cross(x2 - x1).signum()
|
||||
};
|
||||
let mut seed = 11u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut kept, mut flips, mut short) = (0, 0, 0);
|
||||
while kept < 1000 {
|
||||
let th1 = rnd() * TAU;
|
||||
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let v2 = o + v(a * th1.cos(), a * th1.sin());
|
||||
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
|
||||
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
|
||||
let to = o + v(tr * tt.cos(), tr * tt.sin());
|
||||
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
|
||||
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
{
|
||||
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
|
||||
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] {
|
||||
sk.points[p].pos = at;
|
||||
}
|
||||
}
|
||||
let before = side(&sketch(&s));
|
||||
drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]);
|
||||
let sk = sketch(&s);
|
||||
if side(&sk) != before {
|
||||
flips += 1;
|
||||
}
|
||||
if sk.points[p3].pos.dist(to) > 1e-6 {
|
||||
short += 1;
|
||||
}
|
||||
}
|
||||
(flips, short)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_two_link_arm_keeps_its_elbow_side() {
|
||||
for links in [Links::Dimensioned, Links::EqualToFixed] {
|
||||
let (flips, short) = arm_drags(links);
|
||||
eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short");
|
||||
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags");
|
||||
assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each step of a drag is seeded on the positions from before it, so even a drag made in one
|
||||
/// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are
|
||||
/// dimensioned or held equal to fixed lines (the seed reads both).
|
||||
#[test]
|
||||
fn a_single_step_arm_drag_keeps_its_elbow_side() {
|
||||
for links in [Links::Dimensioned, Links::EqualToFixed] {
|
||||
let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links));
|
||||
eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short");
|
||||
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags");
|
||||
assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2)
|
||||
/// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged
|
||||
/// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n`
|
||||
/// deterministic cases with the triangle at random turns and on either side. Returns the drags
|
||||
/// that turned the triangle over.
|
||||
fn hanging_triangle_flips(n: usize) -> usize {
|
||||
use std::f64::consts::TAU;
|
||||
let (a, b) = (30.0, 20.0);
|
||||
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
|
||||
let hand = pt(&s, "l2.end");
|
||||
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
|
||||
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
|
||||
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
|
||||
}
|
||||
let id = s.active_sketch.expect("sketch");
|
||||
let sk0 = sketch(&s);
|
||||
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
|
||||
let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end"));
|
||||
let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end"));
|
||||
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
|
||||
let mut seed = 23u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut kept, mut flips) = (0, 0);
|
||||
while kept < n {
|
||||
let th1 = rnd() * TAU;
|
||||
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let v2 = o + v(a * th1.cos(), a * th1.sin());
|
||||
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
|
||||
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
|
||||
let to = o + v(tr * tt.cos(), tr * tt.sin());
|
||||
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
|
||||
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
let turn = rnd() * TAU;
|
||||
let u = solvecraft_geom::Vec2::from_angle(turn);
|
||||
let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt());
|
||||
{
|
||||
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
|
||||
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] {
|
||||
sk.points[p].pos = at;
|
||||
}
|
||||
}
|
||||
let before = side(&s);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]}));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end"));
|
||||
if side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
flips
|
||||
}
|
||||
|
||||
/// A rigid cluster pinned at a single point is placed by its reference pose (turned about the
|
||||
/// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over,
|
||||
/// however few steps the drag takes.
|
||||
#[test]
|
||||
fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() {
|
||||
let flips: Vec<(usize, usize)> =
|
||||
[1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect();
|
||||
eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}");
|
||||
assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}");
|
||||
}
|
||||
|
||||
/// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one
|
||||
/// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave
|
||||
/// the elbow placed there; returns how many of the sketch's own solves that follow put it on
|
||||
/// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`.
|
||||
fn pinned_elbow_flips(n: usize, move_point: bool) -> usize {
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
let start = s.doc.clone();
|
||||
let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum();
|
||||
let before = side(&s);
|
||||
let mut flips = 0;
|
||||
for i in 0..n {
|
||||
s.doc = start.clone();
|
||||
let elbow = pt(&s, "l1.end");
|
||||
// Across the line, spread along it.
|
||||
let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64);
|
||||
let r = if move_point {
|
||||
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]}))
|
||||
} else {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]}))
|
||||
};
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
if side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
flips
|
||||
}
|
||||
|
||||
/// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps
|
||||
/// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged
|
||||
/// across the shoulder-hand line in one step, stays on its side.
|
||||
#[test]
|
||||
fn a_failed_drag_step_keeps_the_last_solved_orientation() {
|
||||
for move_point in [false, true] {
|
||||
let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point));
|
||||
eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips");
|
||||
assert_eq!(flips, 0, "move_point {move_point}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After a failed step the drag follows as far as the geometry can, and `reached` says how far:
|
||||
/// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about
|
||||
/// two thirds of the way.
|
||||
#[test]
|
||||
fn a_drag_follows_up_to_where_its_step_fails() {
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
// Elbow bent so the hand is 49 from the shoulder, along +x.
|
||||
let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0);
|
||||
let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt());
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]}));
|
||||
let reached = r["reached"].as_f64().unwrap_or(f64::NAN);
|
||||
eprintln!("arm hand past its reach in one step: {r}");
|
||||
assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}");
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
}
|
||||
|
||||
/// The test-only step cap limits the steps a drag takes, not the plan tests read, and is
|
||||
/// restored when the capped code panics.
|
||||
#[test]
|
||||
fn the_step_cap_leaves_the_plan_and_is_restored() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
let uncapped = super::drag::step_cap::get();
|
||||
let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]);
|
||||
assert!(plan > 1);
|
||||
assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan);
|
||||
assert_eq!(super::drag::step_cap::get(), uncapped);
|
||||
assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err());
|
||||
assert_eq!(super::drag::step_cap::get(), uncapped);
|
||||
}
|
||||
|
||||
/// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360).
|
||||
fn sweep_deg(s: &Session, arc: &str) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let Some(solvecraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") };
|
||||
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
|
||||
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees()
|
||||
}
|
||||
|
||||
/// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°.
|
||||
fn free_arc(sweep: f64) -> Session {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]}));
|
||||
s
|
||||
}
|
||||
|
||||
fn on_arc(deg: f64) -> [f64; 2] {
|
||||
let t = deg.to_radians();
|
||||
[50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]
|
||||
}
|
||||
|
||||
/// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or
|
||||
/// 330° → 360° → 30°): the arc stops short, in one call or in many.
|
||||
#[test]
|
||||
fn a_dragged_arc_end_never_wraps_through_its_start() {
|
||||
// One call, clockwise through the start: 90° → -60°.
|
||||
let mut s = free_arc(90.0);
|
||||
drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0));
|
||||
let sw = sweep_deg(&s, "a1");
|
||||
eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°");
|
||||
assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°");
|
||||
// One call, counter-clockwise past a full turn: 330° → 390°.
|
||||
let mut s = free_arc(330.0);
|
||||
drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0));
|
||||
let sw = sweep_deg(&s, "a1");
|
||||
eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°");
|
||||
assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°");
|
||||
// 30° calls, clockwise: 90 → -60.
|
||||
let mut s = free_arc(90.0);
|
||||
let mut last = 90.0;
|
||||
let mut log = Vec::new();
|
||||
for deg in [60.0, 30.0, 0.0, -30.0, -60.0] {
|
||||
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
|
||||
last = deg;
|
||||
log.push(sweep_deg(&s, "a1"));
|
||||
}
|
||||
eprintln!("free arc, CW 30° calls: sweeps {log:.1?}");
|
||||
assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}");
|
||||
// 30° calls, counter-clockwise: 90 → 450.
|
||||
let mut s = free_arc(90.0);
|
||||
let mut last = 90.0;
|
||||
let mut log = Vec::new();
|
||||
for deg in (4..=15).map(|i| (i * 30) as f64) {
|
||||
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
|
||||
last = deg;
|
||||
log.push(sweep_deg(&s, "a1"));
|
||||
}
|
||||
eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}");
|
||||
assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}");
|
||||
assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}");
|
||||
}
|
||||
|
||||
/// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner
|
||||
/// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns
|
||||
/// over near 14° (the tangent length passes 40) and comes back on the way up.
|
||||
#[test]
|
||||
fn a_dragged_fillet_stays_in_its_corner() {
|
||||
for (leg, strict) in [(80.0, true), (40.0, false)] {
|
||||
let mut s = new_sketch();
|
||||
let o = v(100.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"}));
|
||||
let sk0 = sketch(&s);
|
||||
let arc = sk0.curves.iter().position(|c| matches!(c.kind, solvecraft_sketch::CurveKind::Arc { .. })).expect("fillet arc");
|
||||
let arc_id = sk0.curves[arc].id.clone();
|
||||
let inside = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let side = |p: solvecraft_geom::Vec2, a: solvecraft_geom::Vec2, b: solvecraft_geom::Vec2| (b - a).cross(p - a).signum();
|
||||
let c = sk.center(arc).expect("centre");
|
||||
let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end"));
|
||||
let r = sk.radius(arc).unwrap_or(0.0);
|
||||
side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6
|
||||
};
|
||||
assert!(inside(&s));
|
||||
let mut path: Vec<f64> = (45..=87).map(|i| (i * 2) as f64).collect();
|
||||
path.extend((7..=87).rev().map(|i| (i * 2) as f64));
|
||||
path.extend((8..=45).map(|i| (i * 2) as f64));
|
||||
let corner = o;
|
||||
let at = |deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
[corner.x + leg * t.cos(), corner.y + leg * t.sin()]
|
||||
};
|
||||
let mut bad = Vec::new();
|
||||
let mut last = at(90.0);
|
||||
for deg in path {
|
||||
let to = at(deg);
|
||||
drag(&mut s, "l2.end", last, to);
|
||||
last = to;
|
||||
let sw = sweep_deg(&s, &arc_id);
|
||||
if !(inside(&s) && sw < 180.0) {
|
||||
bad.push(deg);
|
||||
}
|
||||
}
|
||||
if strict {
|
||||
assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}");
|
||||
}
|
||||
assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing.
|
||||
#[test]
|
||||
fn hostile_drags_change_nothing() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
|
||||
let l0 = snap(&s, "l1");
|
||||
for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] {
|
||||
for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] {
|
||||
for e in ["l1", "l1.end"] {
|
||||
let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()}));
|
||||
assert!(r.is_err(), "{e} {from} {to}: {r:?}");
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
}
|
||||
}
|
||||
assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err());
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
}
|
||||
drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]);
|
||||
drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]);
|
||||
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]}));
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
// A huge (but finite, in-range) jump on a constrained group is capped and never panics.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64);
|
||||
drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]);
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// The solves a drag of `entity` from `from` to `to` would take.
|
||||
fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize {
|
||||
let sk = sketch(s);
|
||||
let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}"));
|
||||
super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1]))
|
||||
}
|
||||
|
||||
/// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one
|
||||
/// held solve, as it was before; only a long one is split.
|
||||
#[test]
|
||||
fn a_short_drag_is_a_single_solve() {
|
||||
let mut s = new_sketch();
|
||||
// Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin).
|
||||
for i in 0..10 {
|
||||
let x = 100.0 + 30.0 * i as f64;
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]}));
|
||||
let c = curve_ids(&r);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10}));
|
||||
}
|
||||
assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1);
|
||||
assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1);
|
||||
drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]);
|
||||
assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9);
|
||||
// A long move (more than an eighth of the 10 mm side) is split into steps.
|
||||
assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1);
|
||||
drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]);
|
||||
assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9);
|
||||
}
|
||||
|
||||
/// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that
|
||||
/// only shares a fixed point with the dragged geometry does not shrink its steps.
|
||||
#[test]
|
||||
fn the_drag_step_ignores_geometry_beyond_a_fixed_point() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
|
||||
let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01}));
|
||||
assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before);
|
||||
assert_eq!(before, 1);
|
||||
// A free radius couples like a point: a line tangent to a circle whose centre is fixed
|
||||
// steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"}));
|
||||
assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6);
|
||||
}
|
||||
|
||||
/// The step counts every size the constraints hold, offsets along an axis too (the solver holds
|
||||
/// their size on either side), except a length between two points the drag holds: a dimensioned
|
||||
/// line slid by its middle keeps its length whatever the step.
|
||||
#[test]
|
||||
fn the_drag_step_counts_every_held_size_but_the_dragged_length() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5}));
|
||||
let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]);
|
||||
eprintln!("0.5 mm line slid 5 mm: {steps} steps");
|
||||
assert_eq!(steps, 1);
|
||||
// B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays
|
||||
// right of A instead of jumping to the other side.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::DistanceX { .. })));
|
||||
let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]);
|
||||
drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]);
|
||||
let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start"));
|
||||
eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}");
|
||||
assert!(steps > 1);
|
||||
assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// A circle shares only its centre with a dragged line's end (coincident): the solve couples
|
||||
/// its centre, never its radius, so a tiny circle does not shrink the step.
|
||||
#[test]
|
||||
fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Coincident { .. })));
|
||||
let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
|
||||
eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps");
|
||||
assert_eq!(steps, 1);
|
||||
}
|
||||
|
||||
/// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand
|
||||
/// through the hole round its shoulder) still lands on a cursor it can reach.
|
||||
#[test]
|
||||
fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
// From (30, 20) to (-30, -20) about the shoulder: the path passes through it.
|
||||
let to = [o.x - 30.0, o.y - 20.0];
|
||||
let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to}));
|
||||
eprintln!("arm hand across the hole: {steps} steps, {r}");
|
||||
assert!(steps > 1);
|
||||
assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end"));
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
}
|
||||
|
||||
/// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to
|
||||
/// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side.
|
||||
#[test]
|
||||
fn a_drag_past_an_unreachable_stretch_keeps_stepping() {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
let elbow = o + v(19.702247520273062, 22.62347105662377);
|
||||
let hand = o + v(39.654744430607906, 21.245840103473398);
|
||||
let to = o + v(-28.82686238241851, -31.24580379872583);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
// A triangle with sides 3.9, 2 and 2 on the hand.
|
||||
let hand = pt(&s, "l2.end");
|
||||
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
|
||||
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
|
||||
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
|
||||
}
|
||||
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
|
||||
let before = side(&s);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]}));
|
||||
eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s));
|
||||
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end"));
|
||||
assert_eq!(side(&s), before, "the triangle turned over");
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
}
|
||||
|
||||
/// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at
|
||||
/// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged.
|
||||
#[test]
|
||||
fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() {
|
||||
// (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad
|
||||
// of the limit).
|
||||
for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] {
|
||||
let mut s = free_arc(sweep);
|
||||
// A dimensioned line hangs off the arc's end.
|
||||
let end = pt(&s, "a1.end");
|
||||
run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()}));
|
||||
let sw0 = sweep_deg(&s, "a1");
|
||||
// The arc's own end.
|
||||
let t = (end - v(50.0, 50.0)).angle() + nudge;
|
||||
let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()];
|
||||
drag(&mut s, "a1.end", [end.x, end.y], to);
|
||||
assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move");
|
||||
// The joined line's far end, which pulls the arc's end along (inwards).
|
||||
let far = pt(&s, "lc.end");
|
||||
let target = v(far.x, far.y + 1e-5 * nudge.signum());
|
||||
drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]);
|
||||
assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move");
|
||||
assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_constrained_feedback_is_per_entity() {
|
||||
let mut s = new_sketch();
|
||||
@@ -669,3 +1247,141 @@ fn pipe_and_sweep_follow_3d_sketch_curves() {
|
||||
assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}");
|
||||
assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}");
|
||||
}
|
||||
|
||||
/// Between two points, or on a line, that don't line up with an axis, an `auto` dimension takes
|
||||
/// its orientation from where its text goes: across from the two the distance between them,
|
||||
/// past that above or below them the horizontal distance, beside them the vertical one (#257).
|
||||
#[test]
|
||||
fn auto_dimension_orientation_follows_where_its_text_is_placed() {
|
||||
use solvecraft_sketch::ConstraintKind;
|
||||
// The ends of a 30 × 40 line: 50 long, and the slope keeps "across" and "past" distinct.
|
||||
let dimension = |end: [f64; 2], entities: Value, extra: Value| {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], end], "connect": false}));
|
||||
let mut p = json!({"entities": entities});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
let r = run(&mut s, "sketch.dimension", p);
|
||||
let sk = sketch(&s);
|
||||
let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension");
|
||||
(kind, r["measured"].as_f64().unwrap_or(f64::NAN), sk)
|
||||
};
|
||||
let points = json!(["l1.start", "l1.end"]);
|
||||
for entities in [points.clone(), json!(["l1"])] {
|
||||
for (at, expected) in [
|
||||
([15.0, 60.0], "horizontal"),
|
||||
([15.0, -20.0], "horizontal"),
|
||||
([-30.0, 5.0], "vertical"),
|
||||
([55.0, 38.0], "vertical"),
|
||||
([-20.0, 25.0], "aligned"),
|
||||
([45.0, 10.0], "aligned"),
|
||||
([-20.0, -20.0], "aligned"),
|
||||
] {
|
||||
let (kind, measured, _) = dimension([30.0, 40.0], entities.clone(), json!({"text_at": at}));
|
||||
let (got, value) = match kind {
|
||||
ConstraintKind::DistanceX { .. } => ("horizontal", 30.0),
|
||||
ConstraintKind::DistanceY { .. } => ("vertical", 40.0),
|
||||
ConstraintKind::Distance { .. } | ConstraintKind::Length { .. } => ("aligned", 50.0),
|
||||
other => panic!("{entities} at {at:?}: {other:?}"),
|
||||
};
|
||||
assert_eq!(got, expected, "{entities} with its text at {at:?}");
|
||||
assert!((measured - value).abs() < 1e-9, "{entities} at {at:?} measured {measured}");
|
||||
}
|
||||
// No text, an explicit type, or two ends already on an axis keep today's dimension.
|
||||
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({}));
|
||||
assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}");
|
||||
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "distance", "text_at": [15, 60]}));
|
||||
assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}");
|
||||
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "vertical", "text_at": [15, 60]}));
|
||||
assert!(matches!(kind, ConstraintKind::DistanceY { .. }), "{kind:?}");
|
||||
let (kind, ..) = dimension([40.0, 0.0], json!(["l1.start", "l1.end"]), json!({"text_at": [60, 5]}));
|
||||
assert!(matches!(kind, ConstraintKind::Distance { .. }), "{kind:?}");
|
||||
}
|
||||
// The chosen dimension drives that coordinate: 24 across X, the 40 in Y kept.
|
||||
let (_, _, sk) = dimension([30.0, 40.0], points, json!({"text_at": [15, 60], "value": 24}));
|
||||
let end = sk.point(sk.resolve_point("l1.end").expect("end")).expect("end point");
|
||||
let start = sk.point(sk.resolve_point("l1.start").expect("start")).expect("start point");
|
||||
assert!(((end.x - start.x).abs() - 24.0).abs() < 1e-6 && ((end.y - start.y).abs() - 40.0).abs() < 1e-6, "{start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster
|
||||
/// decomposition places: pinned at F by two of its corners and held at G by the third. Every
|
||||
/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on.
|
||||
fn hung_arm() -> Session {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
let at = |x: f64, y: f64| json!([o.x + x, o.y + y]);
|
||||
run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]}));
|
||||
for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [id]}));
|
||||
}
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The elbow's side of the shoulder–hand line.
|
||||
fn hung_elbow_side(s: &Session) -> f64 {
|
||||
let b = pt(s, "ab.end");
|
||||
(pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum()
|
||||
}
|
||||
|
||||
/// The hand of `hung_arm` swung by `angle` about the shoulder.
|
||||
fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solvecraft_geom::Vec2 {
|
||||
let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end"));
|
||||
let r = h - b;
|
||||
let (c, si) = (angle.cos(), angle.sin());
|
||||
b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale
|
||||
}
|
||||
|
||||
/// A live drag whose seed runs out of decomposition budget takes the rest of the way in short
|
||||
/// steps (continuation keeps the elbow the seed no longer places).
|
||||
#[test]
|
||||
fn a_drag_out_of_decomposition_budget_takes_short_steps() {
|
||||
let start = hung_arm();
|
||||
let before = hung_elbow_side(&start);
|
||||
let h = pt(&start, "eh.end");
|
||||
let mut flips = 0;
|
||||
for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] {
|
||||
let mut s = hung_arm();
|
||||
let to = hung_hand_target(&s, k * 0.7, 1.0);
|
||||
let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]);
|
||||
let r = super::drag::last_drag::with_frame_work(0, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true}))
|
||||
});
|
||||
let d = super::drag::last_drag::get();
|
||||
assert!(d.cut, "{d:?}");
|
||||
assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps");
|
||||
assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}");
|
||||
if hung_elbow_side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(flips, 0);
|
||||
}
|
||||
|
||||
/// All the held solves of one live frame, halvings included, share one decomposition budget:
|
||||
/// together they do at most a frame's work, and one solve's more.
|
||||
#[test]
|
||||
fn a_live_drag_shares_one_decomposition_budget() {
|
||||
let mut s = hung_arm();
|
||||
let h = pt(&s, "eh.end");
|
||||
let near = hung_hand_target(&s, 0.01, 1.0);
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true}));
|
||||
let one = super::drag::last_drag::get();
|
||||
assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}");
|
||||
// Swung round and out past the arm's reach: many steps, then halvings.
|
||||
let mut s = hung_arm();
|
||||
let far = hung_hand_target(&s, 2.0, 1.5);
|
||||
let budget = 3 * one.work;
|
||||
super::drag::last_drag::with_frame_work(budget, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true}))
|
||||
});
|
||||
let d = super::drag::last_drag::get();
|
||||
assert!(d.count > 6, "{d:?}");
|
||||
assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}");
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 500 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user