Compare commits

..
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 c650d1867f Geometry comparison helpers: STL reader, mesh inertia, sampled Hausdorff distance
spatial.rs in solvecraft-cli: read binary or ASCII STL; bounds, volume, centre
of mass and principal second moments of a closed triangle mesh (signed
tetrahedra, Jacobi eigenvalues); sampled symmetric Hausdorff distance between
two meshes using a uniform grid with an expanding-shell search. Unit test on a
cube (moments and a 0.3 mm offset). Not called from CLI commands yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:54:14 +00:00
498 changed files with 6041 additions and 61012 deletions

No files matched your search

-29
View File
@@ -1,29 +0,0 @@
# 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"
+15 -26
View File
@@ -3,52 +3,41 @@ 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:
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
**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.
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
**Which file?**
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
`-portable.zip` (x64, x86 and arm64).
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
- **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.
- **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.
- `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 solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solvecraft
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
flatpak install --user solvecraft ai.storyteller.solvecraft
flatpak run ai.storyteller.solvecraft
```
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
+11 -123
View File
@@ -1,15 +1,9 @@
# 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:
@@ -22,8 +16,7 @@ permissions:
concurrency:
group: ci-${{ github.ref }}
# Every push to main gets its full run; a new PR push replaces the old run.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
@@ -46,7 +39,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
native=false
if [ "$EVENT_NAME" != pull_request ]; then
if [ "$EVENT_NAME" = workflow_dispatch ]; then
native=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
@@ -99,12 +92,12 @@ jobs:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
build-tests:
name: Build tests
tests:
name: Native Linux tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 90
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -120,117 +113,18 @@ jobs:
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- 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: 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
- 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, build-tests, tests, doc-tests, oracle]
needs: [changes, checks, clippy, tests]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -240,19 +134,13 @@ 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:
-47
View File
@@ -1,47 +0,0 @@
# 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
+2 -2
View File
@@ -52,8 +52,8 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.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'] == 'solidcraft'"
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'"
+23 -28
View File
@@ -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 solidcraft-cli (no image files are committed), for the
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
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 solidcraft-cli
- run: cargo build --release --locked -p solvecraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
make_icons target/release/solvecraft-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:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -135,22 +135,17 @@ jobs:
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
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
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
rpm -qip dist/release/*.rpm
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
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
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 }}
@@ -171,8 +166,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -186,7 +181,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/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
@@ -203,8 +198,8 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
@@ -216,7 +211,7 @@ jobs:
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
envs: "SOLVECRAFT_VERSION SOLVECRAFT_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
@@ -226,10 +221,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/solidcraft-target CARGO_BUILD_JOBS=4
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
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$'
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$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
@@ -242,8 +237,8 @@ jobs:
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -274,7 +269,7 @@ jobs:
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -295,7 +290,7 @@ jobs:
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -309,7 +304,7 @@ jobs:
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+25 -30
View File
@@ -16,7 +16,9 @@ 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.
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).
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
@@ -41,22 +43,18 @@ 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
- **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
- **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
operations are our own.
- **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.
- **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.
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
@@ -73,12 +71,10 @@ 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. 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.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -115,7 +111,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 solidcraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solvecraft -- --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}}}`
@@ -125,12 +121,11 @@ 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`.
- 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`.
- 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`.
## Roadmap
`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.
`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`.
+1 -22
View File
@@ -26,27 +26,6 @@ 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
@@ -76,7 +55,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; face and edge history of booleans; trace macro (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; trace macro (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
+25 -30
View File
@@ -16,7 +16,9 @@ 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.
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).
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
@@ -41,22 +43,18 @@ 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
- **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
- **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
operations are our own.
- **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.
- **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.
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
@@ -73,12 +71,10 @@ 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. 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.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -92,7 +88,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 solidcraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solvecraft -- --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}}}`
@@ -102,12 +98,11 @@ 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`.
- 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`.
- 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`.
## Roadmap
`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.
`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`.
+16 -36
View File
@@ -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 solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
SolidCraft is the product and repository name. Cargo packages and executables retain
their `solvecraft` names for compatibility with existing scripts and MCP clients.
Build the desktop app with `cargo build --release -p 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`
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
`cargo build --release -p solvecraft-cli`. Run the CLI with
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
after building). There is no Cargo package named `solidcraft`.
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
### NixOS: `nix develop`
@@ -88,16 +88,13 @@ The [README](README.md#architecture) has the layer table. The short version:
## Rules that aren't negotiable
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`).
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).
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. (Running an
open-source application to get reference values for a test is fine; reading its source is
not.) Permissive
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
3. **Every asset is original or openly licensed and has a row in
@@ -122,7 +119,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` |
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (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.
@@ -153,28 +150,11 @@ 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.
**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.
**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.
## Sending a change
Generated
+15 -16
View File
@@ -3289,8 +3289,8 @@ dependencies = [
]
[[package]]
name = "solidcraft"
version = "0.5.0"
name = "solvecraft"
version = "0.3.2"
dependencies = [
"eframe",
"egui",
@@ -3306,8 +3306,8 @@ dependencies = [
]
[[package]]
name = "solidcraft-cli"
version = "0.5.0"
name = "solvecraft-cli"
version = "0.3.2"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3316,7 +3316,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"log",
"serde",
@@ -3331,7 +3331,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"log",
"serde",
@@ -3347,16 +3347,15 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "solvecraft-io"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3368,7 +3367,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"log",
"serde",
@@ -3383,7 +3382,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3391,7 +3390,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"image",
"serde",
@@ -3401,7 +3400,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"serde",
"serde_json",
@@ -3412,7 +3411,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"egui",
"egui-wgpu",
@@ -3427,7 +3426,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"eframe",
"egui",
@@ -4850,7 +4849,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.3.2"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.5.0"
version = "0.3.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+2 -3
View File
@@ -19,9 +19,8 @@ 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.
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.
None of the bundled assets come from third parties: every icon is drawn in code. 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
+14 -34
View File
@@ -17,11 +17,10 @@ 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.** 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).
**Status: early but usable for simple parts.** The historical benchmark report records
76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)).
These checks do not establish complete geometric or workflow equivalence. Feature coverage
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -33,9 +32,6 @@ 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.
@@ -81,8 +77,7 @@ 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). Convert Mesh reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
need B-rep bodies);
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
@@ -100,36 +95,22 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
## Build and run
```sh
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 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 xtask ci # fmt, clippy, tests, assets, layers, wasm
```
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
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).
@@ -185,7 +166,6 @@ 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.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
References to other products describe workflow comparisons and benchmark provenance.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
Fusion are trademarks of Autodesk, Inc.
+18 -61
View File
@@ -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. |
| 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. |
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
@@ -20,7 +20,8 @@ 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. See [WIP reconciliation](docs/wip-reconciliation.md).
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).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -37,23 +38,24 @@ 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.
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
headline counts in-scope tabs only and lists the deferred ones separately.
## 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. 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 |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [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 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; 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. 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 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| 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 |
@@ -61,30 +63,13 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## 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.
@@ -137,8 +122,6 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
## 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
@@ -164,8 +147,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs).
edges.
- 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;
@@ -179,15 +161,16 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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) reads solids (B-rep with analytic and B-spline geometry), units,
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; 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 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).
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
@@ -254,10 +237,6 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
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.
@@ -310,33 +289,17 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
- 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. Under-resolved fits return an error.
check, not a new Fusion oracle result. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## 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
@@ -350,12 +313,6 @@ and reference-object placement remain separate work, as do loft rails and center
## 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.
-110
View File
@@ -13345,113 +13345,3 @@ 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.
+2 -9
View File
@@ -1,23 +1,16 @@
[package]
name = "solidcraft-cli"
name = "solvecraft-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 = "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"
path = "src/main.rs"
[dependencies]
solvecraft-engine = { workspace = true }
-30
View File
@@ -1,30 +0,0 @@
//! `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 -40
View File
@@ -1,4 +1,4 @@
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//! `solvecraft-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,8 +33,6 @@ 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() {
@@ -181,23 +179,9 @@ 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() {
@@ -236,28 +220,5 @@ 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}");
}
}
+35 -35
View File
@@ -1,17 +1,18 @@
//! SolveCraft headless command line.
//!
//! ```text
//! 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)
//! 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)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -19,6 +20,9 @@
mod fuzz;
mod oracle;
mod recipe;
mod seams;
// Geometry comparison helpers; not called from the CLI commands yet.
#[cfg_attr(not(test), allow(dead_code))]
mod spatial;
use std::process::ExitCode;
@@ -31,19 +35,20 @@ use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
const USAGE: &str = "usage:
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
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
";
fn main() -> ExitCode {
@@ -71,6 +76,7 @@ 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..]),
@@ -87,7 +93,7 @@ fn main() -> ExitCode {
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
Some("--version" | "-V") => {
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
_ => Err(USAGE.to_string()),
@@ -286,13 +292,7 @@ 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| {
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"
)
})?),
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
};
@@ -305,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: solidcraft-cli save-stress <path> <rounds>".into());
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
@@ -327,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: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
return Err("usage: solvecraft-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 };
@@ -355,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: solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
let path = args.first().ok_or("usage: solvecraft-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 };
+167 -153
View File
@@ -1,149 +1,129 @@
//! 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)]
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
//! compare them with the same measurements.
use serde_json::{Value, json};
use solvecraft_engine::geom::Vec3;
use crate::spatial::{self, Tri};
use crate::{read_json, recipe};
use crate::{read_json, recipe, seams};
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
/// 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;
/// 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);
}
}
/// 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",
)];
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 recipe = match read_json(&format!("{dir}/recipe.json")) {
Ok(r) => r,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
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 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(), "source": want.as_ref().map(|w| w["source"].clone())});
return json!({"case": name, "pass": false, "error": e.to_string()});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => totals(&m),
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
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});
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 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);
// 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);
}
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);
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);
}
}
}
// 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.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() {
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 {
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
}
@@ -183,43 +163,77 @@ fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, Stri
}
}
/// 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]);
/// 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()
}
}
})
.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);
}
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
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"]},
})
}
/// 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);
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(()) }
}
fn report(args: &[String], results: &[Value]) {
@@ -254,7 +268,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 (each with recipe.json and expected.json)".into());
return Err("oracle: give one or more case directories".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
+11 -3
View File
@@ -1,4 +1,4 @@
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts.
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft 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,6 +85,14 @@ 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);
@@ -537,8 +545,8 @@ fn edge_points(f: &Value) -> Value {
)
}
/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are
/// the recipe's own names.
/// Fusion names a pattern's source features with 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)
+321
View File
@@ -0,0 +1,321 @@
//! 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 &deg {
// 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)));
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
#[test]
fn registry_output_is_complete_json() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let commands = registry.as_array().unwrap();
@@ -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_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
// Close our read end before waiting. No sleeps or timing threshold are needed:
// the registry is larger than the pipe buffer, so it cannot all be written first.
drop(child.stdout.take());
@@ -15,8 +15,6 @@ 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.
@@ -1,144 +0,0 @@
{
"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
}
}
@@ -1,373 +0,0 @@
{
"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
}
@@ -1,111 +0,0 @@
{
"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"
}
}
]
}
@@ -1,107 +0,0 @@
{
"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"
}
}
]
}
@@ -1,171 +0,0 @@
{
"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
}
}
@@ -1,366 +0,0 @@
{
"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,44 +90,6 @@ 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 {
+3 -3
View File
@@ -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_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
@@ -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_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
}
#[test]
fn maximum_seed_runs_once_without_wrapping() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
.output()
.unwrap();
+1 -1
View File
@@ -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_solidcraft-cli");
let exe = env!("CARGO_BIN_EXE_solvecraft-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();
-20
View File
@@ -1,20 +0,0 @@
//! 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);
}
-18
View File
@@ -1,18 +0,0 @@
//! `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}");
}
+1 -1
View File
@@ -31,7 +31,7 @@ impl Drop for Scratch {
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
}
fn json_output(output: Output) -> Value {
+1 -1
View File
@@ -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", "Response",
"BeforeUnloadEvent", "Event", "EventTarget",
] }
Binary file not shown.
-93
View File
@@ -1,93 +0,0 @@
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.
-2
View File
@@ -5,8 +5,6 @@
<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; }
-59
View File
@@ -46,10 +46,6 @@ 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>,
@@ -62,9 +58,6 @@ 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 {
@@ -311,42 +304,10 @@ 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)));
@@ -362,11 +323,7 @@ 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;
}
}
@@ -377,19 +334,6 @@ 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 {
@@ -411,7 +355,6 @@ 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();
@@ -455,8 +398,6 @@ mod web {
renaming: None,
rename_focus: false,
recents_synced: false,
font: Default::default(),
font_result: Rc::new(std::cell::RefCell::new(None)),
}))
}),
)
+3 -5
View File
@@ -1,11 +1,11 @@
[package]
name = "solidcraft"
name = "solvecraft"
description = "SolidCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
default-run = "solidcraft"
default-run = "solvecraft"
[lints]
workspace = true
@@ -19,11 +19,9 @@ 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"] }
+1 -1
View File
@@ -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, ["solidcraft"], embed_resource::NONE).manifest_required()?;
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
+11 -54
View File
@@ -15,7 +15,7 @@
#[path = "../../app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(target_os = "windows")]
mod space_mouse;
use solvecraft_engine::Session;
@@ -23,7 +23,7 @@ use solvecraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(target_os = "windows")]
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(any(target_os = "windows", target_os = "linux"))]
#[cfg(target_os = "windows")]
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!("solidcraft {}", env!("CARGO_PKG_VERSION"));
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!("usage: solidcraft [--control PORT] [--sample] [design.solvecraft | part.step]");
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
return Ok(());
}
_ => files.push(a),
@@ -175,12 +175,10 @@ fn main() -> eframe::Result {
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
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(),
) {
// 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() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
@@ -200,8 +198,6 @@ 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());
@@ -229,56 +225,17 @@ 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(any(target_os = "windows", target_os = "linux"))]
#[cfg(target_os = "windows")]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
Ok(Box::new(App {
solve: app,
#[cfg(any(target_os = "windows", target_os = "linux"))]
#[cfg(target_os = "windows")]
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]
+19 -131
View File
@@ -1,17 +1,10 @@
//! 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;
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
//! it publishes the latest axes, and the egui thread applies them once per frame.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hid_descriptor::Layout;
#[cfg(target_os = "windows")]
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
@@ -215,14 +208,9 @@ 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 {
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
@@ -232,18 +220,8 @@ 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.
/// 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 {
fn decode_report(report: &[u8], 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;
@@ -260,24 +238,6 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst
}
}
/// 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");
@@ -287,72 +247,27 @@ 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) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
Ok(size) => {
if let Ok(mut reading) = shared.lock()
&& decode_report(&report[..size], &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
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(_) => {}
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
std::thread::sleep(RETRY_AFTER);
}
@@ -456,46 +371,19 @@ 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], Layout::Split, &mut reading, now));
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], 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!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn 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();
@@ -1,253 +0,0 @@
//! 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);
}
}
}
-57
View File
@@ -1,57 +0,0 @@
//! 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:?}");
}
}
}
+1 -1
View File
@@ -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::same(raw);
body.names = crate::naming::NameCell::known(crate::naming::face_names(raw));
let slot = output.bodies.iter_mut().find(|body| body.name == raw.name).ok_or_else(|| DocError::Unknown("anchored body frame".into()))?;
*slot = body;
Ok(Cow::Owned(output))
+2 -1
View File
@@ -217,7 +217,8 @@ impl Document {
}
// By name (every piece of a split face), else by the point.
if let Some(n) = &f.name {
let hits = crate::naming::faces_with_stem(b, n);
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();
if !hits.is_empty() {
for i in hits {
out.retain(|(j, _)| *j != i);
-1
View File
@@ -329,7 +329,6 @@ 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, .. } => {
+1 -1
View File
@@ -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::same(old);
body.names = crate::naming::NameCell::known(crate::naming::face_names(old));
let slot = next.bodies.iter_mut().find(|b| b.name == *name).ok_or_else(|| DocError::Unknown(format!("transferred body `{name}`")))?;
*slot = body;
}
+4 -12
View File
@@ -80,10 +80,6 @@ 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:")
@@ -95,9 +91,7 @@ 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..];
// 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','))) {
if rest.starts_with('F') && (at == 0 || matches!(name.as_bytes().get(at - 1), Some(b'@') | Some(b':') | Some(b'|'))) {
let end = rest.as_bytes().iter().skip(1).take_while(|b| b.is_ascii_digit()).count() + 1;
if end > 1
&& matches!(rest.as_bytes().get(end), Some(b':') | Some(b'.'))
@@ -362,11 +356,9 @@ impl crate::Feature {
mapped_name(n, bodies);
}
}
K::Split { body, .. }
| K::SheetConvert { body, .. }
| K::SurfaceExtend { body, .. }
| K::SurfaceTrim { body, .. }
| K::MeshConvert { body, .. } => mapped_name(body, bodies),
K::Split { body, .. } | K::SheetConvert { body, .. } | K::SurfaceExtend { body, .. } | K::SurfaceTrim { body, .. } => {
mapped_name(body, bodies)
}
_ => {}
}
match &mut self.kind {
+29 -116
View File
@@ -68,12 +68,12 @@ pub(crate) fn cap_key(outer: &[String], holes: &[Vec<String>]) -> String {
}
format!("{:x}", hash.finalize())
}
fn exact_region_signature(curves: &ExactSketchCurves, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
pub(crate) fn exact_region_signature(sketch: &SolvedSketch, region: &solvecraft_geom::Region2) -> Option<solvecraft_kernel::CapSignature> {
if region.outer.segs.len() + region.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() > 4096 || region.holes.len() > 128 {
return None;
}
let signature = |outline: &solvecraft_geom::Loop2| {
let mut ids = outline.segs.iter().map(|segment| curves.find(segment)).collect::<Option<Vec<_>>>()?;
let mut ids = outline.segs.iter().map(|segment| exact_sketch_curve(sketch, segment)).collect::<Option<Vec<_>>>()?;
ids.sort();
ids.dedup();
Some(ids)
@@ -112,7 +112,6 @@ 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 {
@@ -122,7 +121,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(&curves, region),
_ => exact_region_signature(sketch, region),
};
cached = Some((region, signature.clone()));
if let Some((outer, holes)) = &signature {
@@ -308,55 +307,36 @@ 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) 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 };
}
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if curve.construction
|| !matches!(
curve.kind,
solvecraft_sketch::CurveKind::Line { .. }
| solvecraft_sketch::CurveKind::Arc { .. }
| solvecraft_sketch::CurveKind::Circle { .. }
)
{
continue;
}
let 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 exact_sketch_curve(sketch: &SolvedSketch, generated: &Seg2) -> Option<String> {
if sketch.sketch.curves.len() > 2000 {
return None;
}
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);
let mut found = None;
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
if curve.construction
|| !matches!(
curve.kind,
solvecraft_sketch::CurveKind::Line { .. } | solvecraft_sketch::CurveKind::Arc { .. } | solvecraft_sketch::CurveKind::Circle { .. }
)
{
continue;
}
let matches = if let solvecraft_sketch::CurveKind::Circle { c, r } = curve.kind {
sketch
.sketch
.point(c)
.is_some_and(|center| exact_segment_contains(&Seg2::Arc { center, radius: r, start: 0., sweep: std::f64::consts::TAU }, generated))
} else {
sketch.sketch.segs(index).iter().any(|original| exact_segment_contains(original, generated))
};
if matches {
if found.is_some() {
return None;
}
found = Some(curve.id.clone());
}
found.map(str::to_owned)
}
found
}
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
@@ -506,73 +486,6 @@ 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 [
+6 -9
View File
@@ -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(&crate::format::format_string(crate::format::FORMAT)));
assert!(saved.contains("solvecraft/13"));
let loaded = crate::format::read(&saved).unwrap();
let mut cold = Model::new();
cold.evaluate(&loaded);
@@ -176,11 +176,10 @@ 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"], crate::format::format_string(crate::format::FORMAT));
assert_eq!(current["format"], "solvecraft/13");
current["format"] = json!("solvecraft/12");
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_eq!(legacy, current, "identity migration must preserve every prior field");
legacy["format"] = json!("solvecraft/14");
assert!(crate::format::read(&legacy.to_string()).is_err());
}
@@ -254,10 +253,8 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
depth: 1,
mats: vec![],
})));
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 former = crate::naming::face_name_at_body(&old_body, Vec3::new(20., 6., 1.)).unwrap();
assert_eq!(former, format!("{stem}#1"), "historical actual strip capture records full piece name");
let mut sketch = solvecraft_sketch::Sketch::new();
let corners = [Vec2::new(0., 0.), Vec2::new(12., 0.), Vec2::new(12., 2.), Vec2::new(0., 2.)];
let geometry: Vec<_> = (0..4).map(|i| solvecraft_sketch::LinkGeom::Line(corners[i], corners[(i + 1) % 4])).collect();
-8
View File
@@ -202,14 +202,6 @@ 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 {
+3 -42
View File
@@ -883,12 +883,8 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
components: Vec<solvecraft_kernel::StepNode>,
},
/// 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.
/// 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.
MeshImport {
/// Source file name (display only).
file: String,
@@ -1166,7 +1162,6 @@ impl FeatureKind {
| K::Move { .. }
| K::Import { .. }
| K::MeshImport { .. }
| K::MeshConvert { .. }
| K::BodyTransfer { .. } => Vec::new(),
}
}
@@ -1252,7 +1247,6 @@ impl FeatureKind {
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
FeatureKind::MeshConvert { .. } => "MeshConvertFeature",
}
}
/// Default name prefix (`Extrude` → `Extrude1`).
@@ -1318,7 +1312,6 @@ impl FeatureKind {
FeatureKind::SurfaceExtend { .. } => "Extend",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
FeatureKind::MeshConvert { .. } => "ConvertMesh",
}
}
/// Every expression the feature uses.
@@ -1527,7 +1520,6 @@ 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),
@@ -1544,8 +1536,7 @@ pub struct Feature {
pub name: String,
#[serde(default)]
pub suppressed: bool,
/// 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`).
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
#[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`.
@@ -1568,13 +1559,6 @@ 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,
}
@@ -1909,7 +1893,6 @@ impl Document {
edge_names: Vec::new(),
face_names: Vec::new(),
participants: Vec::new(),
shell_piece_order: false,
kind,
};
match self.marker {
@@ -2184,28 +2167,6 @@ 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.
+208 -596
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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_has_name(&mb, index as usize, name) {
if crate::naming::face_names(&mb).get(index as usize) != Some(name) {
return Err(DocError::Invalid("selected cylinder face is ambiguous".into()));
}
at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?;
@@ -687,7 +687,7 @@ pub(super) fn sheet_cut(
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
continue;
}
for bends in sh.unmodeled_solids(&ss.plane)? {
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
for tool in tools {
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
return Err(DocError::Invalid(
+4 -203
View File
@@ -39,20 +39,13 @@
//! - 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 = 14;
pub const FORMAT: u32 = 13;
pub fn format_string(n: u32) -> String {
format!("solvecraft/{n}")
@@ -65,129 +58,8 @@ 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; 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);
}
}
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(())];
/// 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,
@@ -724,74 +596,6 @@ 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;
@@ -1202,7 +1006,6 @@ 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));
@@ -1254,11 +1057,9 @@ 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 mut features = legacy["features"].clone();
pieces_in_shell_order(&mut features);
let features = legacy["features"].clone();
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);
-396
View File
@@ -1,396 +0,0 @@
//! 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;
-68
View File
@@ -1,68 +0,0 @@
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));
}
-4
View File
@@ -22,15 +22,11 @@ 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;
-388
View File
@@ -1,388 +0,0 @@
//! 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(&copy, &[&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;
}
}
}
}
-333
View File
@@ -1,333 +0,0 @@
//! 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)
}
+127 -131
View File
@@ -1,26 +1,16 @@
//! Persistent names of faces and edges.
//! Persistent names of faces and edges (plan/adr/0002-persistent-naming.md).
//!
//! 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.
//! 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.
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.
@@ -37,43 +27,21 @@ pub struct NamingOrigin {
#[derive(Clone, Debug, Default)]
pub struct NameCell {
pub origin: Option<Arc<NamingOrigin>>,
/// 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>>,
names: Arc<OnceLock<Arc<Vec<String>>>>,
}
impl NameCell {
pub fn new(origin: Option<Arc<NamingOrigin>>) -> Self {
Self::traced(origin, None)
NameCell { origin, names: Arc::new(OnceLock::new()) }
}
/// 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.
/// Names already known (a moved copy of a body keeps its faces' names).
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 { names: Arc::new(cell), copy_of, ..NameCell::default() }
NameCell { origin: None, names: Arc::new(cell) }
}
}
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;
@@ -112,7 +80,7 @@ fn samples(m: &Mesh, nf: usize) -> Vec<Vec<Vec3>> {
.collect()
}
pub(crate) fn closest(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 {
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 {
@@ -190,53 +158,12 @@ 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 => {
let mut names = geometric_from(b, o, nf, legacy_face_names);
number_pieces(&mut names, &samples(&b.mesh(), nf), '#');
names
}
Some(o) if o.depth < MAX_DEPTH => derive(b, o, nf),
_ => (0..nf).map(|k| format!("{}:face:{k}", b.name)).collect(),
};
Arc::new(names)
@@ -244,31 +171,14 @@ pub(crate) fn legacy_face_names(b: &ModelBody) -> Arc<Vec<String>> {
.clone()
}
/// 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> {
/// 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> {
// 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 names_of(input).as_ref().clone();
return face_names(input).as_ref().clone();
}
let m = b.mesh();
let size = b.body.size().max(1e-6);
@@ -304,7 +214,7 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
.take(64)
.map(|x| {
let n = face_count(x);
(x, FaceIndex::new(&x.mesh(), n), names_of(x))
(x, FaceIndex::new(&x.mesh(), n), face_names(x))
})
.collect();
let fid = o.feature.id;
@@ -319,7 +229,7 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
.iter()
.filter(|x| !x.body.is_mesh())
.take(64)
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), names_of(x)))
.map(|x| (x, FaceIndex::new(&x.mesh(), face_count(x)), face_names(x)))
.collect();
let mut best: Option<(usize, usize, Vec<Option<String>>)> = None;
for (k, mat) in o.mats.iter().enumerate() {
@@ -335,7 +245,7 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
}
}
let empty = Vec::new();
let names: Vec<String> = names
let mut names: Vec<String> = names
.into_iter()
.enumerate()
.map(|(fi, n)| {
@@ -351,6 +261,7 @@ fn geometric_from(b: &ModelBody, o: &NamingOrigin, nf: usize, names_of: fn(&Mode
})
})
.collect();
number_pieces(&mut names, &pts, '#');
names
}
@@ -436,23 +347,37 @@ 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 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).
/// A face name without its piece number (`#k`).
pub fn strip_piece(n: &str) -> &str {
let mut stem = n;
while let Some(parent) = parent_piece(stem) {
stem = parent;
}
stem
strip_mark(n, '#')
}
/// 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.
/// 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.
pub fn strip_edge_piece(n: &str) -> String {
crate::piece_names::strip_edge_piece(n)
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
}
/// Edges sharing a name: number them along the longest axis of their spread.
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.
fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
let mut seen: Vec<String> = Vec::new();
for i in 0..names.len() {
@@ -504,7 +429,18 @@ 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.
return format!("F{fid}:{}", crate::lineage::extrude_cap_role((c - ss.plane.origin).dot(n)));
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"
}
);
}
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}"))
@@ -640,12 +576,8 @@ 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()
@@ -663,7 +595,35 @@ fn edge_names_from(b: &ModelBody, faces: &[String]) -> 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> {
locate_edge(b, name, near).map(|hit| (hit.point, hit.split))
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"))
}
/// Names of the edges nearest each point on a body (for storing references).
@@ -715,10 +675,28 @@ 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 "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
/// `true` for "it was split"); `None` when no face has the name.
pub fn point_on_face(st: &ModelState, name: &str, p: Vec3) -> Option<(Vec3, bool)> {
locate_face(st, name, p).map(|hit| (hit.point, hit.split || hit.merged))
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))
}
/// Resolve a legacy named face only on its recorded source body. Return the
@@ -728,7 +706,25 @@ pub fn face_on_body(body: &ModelBody, name: &str, p: Vec3) -> Option<(usize, Vec
if body.body.is_mesh() {
return None;
}
crate::name_lookup::locate_on(body, name, p).map(|hit| (hit.face, hit.point, hit.split || hit.merged))
let names = face_names(body);
let mesh = body.mesh();
let stem = strip_piece(name);
let mut best: Option<(f64, usize, Vec3, bool)> = None;
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
let index = usize::try_from(*face).ok()?;
let Some(candidate) = names.get(index) else { continue };
let exact = candidate == name;
if !exact && strip_piece(candidate) != stem {
continue;
}
let Some([a, b, c]) = mesh.tri(triangle) else { continue };
let q = closest(p, a, b, c);
let distance = q.dist(p) - if exact { 1e-9 } else { 0. };
if best.is_none_or(|(old, _, _, _)| distance < old) {
best = Some((distance, index, q, !exact));
}
}
best.map(|(_, index, q, split)| (index, q, split))
}
#[cfg(test)]
-1
View File
@@ -259,7 +259,6 @@ 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)),
-139
View File
@@ -1,139 +0,0 @@
//! 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,
}
}
+1 -1
View File
@@ -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_has_name(target, index, stem)
&& crate::naming::face_names(target).get(index).is_some_and(|current| current == stem)
});
if removed_piece {
sk.set_link_lost(&l.id, true);
-6
View File
@@ -9,13 +9,7 @@ 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)
}
+15 -249
View File
@@ -879,13 +879,10 @@ impl SheetBody {
Ok(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>> {
/// Actual curved material, used to certify unsupported folded machining before mutation.
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
if self.flat || self.flanges.is_empty() {
return Ok(Vec::new());
return Ok(None);
}
let mut work = FlatWork::new();
let cells: Vec<_> = self
@@ -896,7 +893,10 @@ impl SheetBody {
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
})
.collect();
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
if cells.is_empty() {
return Ok(None);
}
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
}
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
@@ -1134,29 +1134,7 @@ impl SheetBody {
match panel {
None => {
let pts = self.base.outer.ccw().polyline(1e-3);
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())),
};
}
let cut = clip(&pts, a + inward * by, inward);
if cut.len() < 3 {
return Err(DocError::Invalid("the flange's setback removes the whole panel".into()));
}
@@ -1212,13 +1190,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, b, d, top));
found.push((panel, a, d, top));
}
for (panel, a, b, _, _) in &found {
self.trim(*panel, *a, *b, setback)?;
for (panel, a, d, _) in &found {
self.trim(*panel, *a, *a + *d, setback)?;
}
let mut made = Vec::new();
for (panel, a, b, d, top) in found {
for (panel, a, 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.
@@ -1226,21 +1204,14 @@ impl SheetBody {
None => {
let pts = self.base.outer.ccw().polyline(1e-3);
let m = pts.len();
let on_line: Vec<(Vec2, f64)> = (0..m)
(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()))
})
.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()
.next()
.ok_or_else(|| DocError::Invalid("the flange's edge was trimmed away".into()))?
}
Some(i) => {
@@ -1384,29 +1355,7 @@ impl SheetBody {
}
(None, Some(p)) => {
let q = apply_point(&inv, p);
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
Vec2::new(q.x, q.y).dot(n) < l0
}
(None, None) => side_area(n) <= side_area(n * -1.0),
};
@@ -1429,32 +1378,11 @@ 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;
@@ -1508,135 +1436,6 @@ 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> {
@@ -2250,37 +2049,4 @@ 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,27 +64,3 @@ 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);
}
+3 -6
View File
@@ -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().doc_only(),
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
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,7 +312,6 @@ 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.
@@ -347,7 +346,6 @@ 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 }))
}
@@ -447,14 +445,13 @@ 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; its siblings keep the names they have now
// (unpinned pattern copies would otherwise be renumbered into the freed name, #265).
// The body is the k-th new body of its feature; the names before it are kept as they are.
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() < siblings.len().max(k + 1) {
while f.body_names.len() <= k {
let i = f.body_names.len();
f.body_names.push(siblings.get(i).cloned().unwrap_or_default());
}
+1 -2
View File
@@ -393,9 +393,8 @@ 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.settle_value_edit(&solved)?;
s.refresh();
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
let mut out = show(s, p)?;
out["errors"] = json!(errors);
@@ -108,25 +108,7 @@ 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);
@@ -174,10 +156,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());
// 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.)
// Preserve the legacy centroid-family name even though strict paint uses
// the separately certified outer source curve.
let name = solvecraft_doc::naming::face_names_at(&s.model.state(), &[Vec3::new(0., 10., 7.5)]).remove(0);
assert!(name.contains("side:outer"));
assert!(name.contains("side:inner"));
run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]}));
run(&mut s, "sketch.finish", json!({}));
@@ -190,13 +172,6 @@ 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);
+2 -2
View File
@@ -19,7 +19,8 @@ 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()))?;
let r = spec.run_nested(s, &p)?;
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(s, &p)?;
s.refresh();
Ok(r)
}
@@ -89,7 +90,6 @@ 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();
+20 -44
View File
@@ -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().doc_only(),
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
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.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) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
.params("features: [id or name] (a sketch takes its dependent features with it)"),
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,33 +51,26 @@ 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 now = go_to(s, prev);
let label = now.label.clone();
s.redo.push(now);
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();
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 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;
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();
// A new document is evaluated by `execute`.
if same {
s.refresh();
}
was
s.refresh();
Ok(json!({"redone": label}))
}
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
@@ -127,9 +120,6 @@ 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 {
@@ -144,18 +134,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
s.active_sketch = None;
}
s.selection.clear();
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}))
Ok(json!({"deleted": gone}))
}
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
@@ -166,9 +145,6 @@ 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();
}
@@ -285,7 +261,6 @@ 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),
}))
}
@@ -484,7 +459,8 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
}
trial.doc_mut().marker = Some(idx + 1);
trial.refresh();
let r = spec.run_nested(&mut trial, &params)?;
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
let r = (spec.run)(&mut trial, &params)?;
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)
-44
View File
@@ -185,47 +185,3 @@ 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}");
}
+14 -100
View File
@@ -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?, 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?"),
.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?"),
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, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"),
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
@@ -359,17 +359,10 @@ 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;
if world {
super::component::to_active_frame(s, &mut kind);
}
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();
@@ -413,19 +406,9 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
solvecraft_doc::naming::face_names_at(&s.model.state(), &pts)
}
};
// 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"),
};
let participants = string_list(p, "participants");
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
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")?;
return Err(EngineError::Other("`participants` must list existing bodies".into()));
}
let id = s.doc_mut().add_feature(kind, name)?;
if let Some(f) = s.doc_mut().feature_mut(id) {
@@ -448,22 +431,10 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
if !names.is_empty()
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.body_names = names.clone();
f.body_names = names;
}
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));
@@ -883,12 +854,7 @@ 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 = 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 origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
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))
}
@@ -999,31 +965,6 @@ 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
@@ -1037,22 +978,7 @@ 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() => {
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)) if solvecraft_geom::Plane::named(n.trim()).is_some() => 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()),
@@ -1076,17 +1002,9 @@ 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 } })
}
@@ -1511,7 +1429,3 @@ 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;
+2 -2
View File
@@ -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("solid.boundary_fill", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params(
CommandSpec::new("SurfaceSculpt", "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 = "solid.boundary_fill";
let cmd = "SurfaceSculpt";
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()) {
+2 -6
View File
@@ -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().doc_only(),
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -205,7 +205,6 @@ 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();
@@ -262,10 +261,7 @@ 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), "exact": solvecraft_doc::naming::face_name_exact(b, f.index),
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})
})
.map(|f| json!({"index": f.index, "name": names.get(f.index), "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal}))
.collect();
Ok(json!({"body": b.name, "faces": faces}))
}
-37
View File
@@ -1,37 +0,0 @@
//! 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 })
}
+4 -43
View File
@@ -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.
//! Design workspace layout. Ids used before the rename still resolve (`legacy_ids`).
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
@@ -26,7 +26,6 @@ mod file;
mod inspect;
pub(crate) mod joints;
mod measure_sel;
mod mesh;
pub(crate) mod motion;
mod parts;
mod section;
@@ -57,10 +56,6 @@ 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 {
@@ -78,40 +73,11 @@ 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,
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
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
@@ -138,12 +104,6 @@ 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 {
@@ -197,7 +157,6 @@ 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());
@@ -219,5 +178,7 @@ 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()
}
+1 -1
View File
@@ -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().doc_only().params("→ sets, enabled, all"),
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().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], …]}] \
@@ -1,82 +0,0 @@
#![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, &params).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}");
}
}
+3 -12
View File
@@ -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().doc_only().params(
CommandSpec::new("parameters.list", "List Parameters", list).noundo().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,11 +38,9 @@ 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}]"),
];
@@ -69,10 +67,8 @@ 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();
@@ -105,7 +101,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.settle_value_edit(&solved)?;
s.refresh();
let (vals, _) = s.doc.param_values();
out["name"] = json!(name);
out["expression"] = json!(e);
@@ -144,9 +140,7 @@ 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)?;
}
@@ -166,9 +160,7 @@ 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}))
}
@@ -246,9 +238,8 @@ 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.settle_value_edit(&solved)?;
s.refresh();
Ok(json!({"imported": names, "errors": failing(s)}))
}
+4 -305
View File
@@ -67,11 +67,9 @@ fn width_follows_an_expression() {
assert!(e.contains("expected a length, got an angle"), "{e}");
}
/// 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.
/// 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.
#[test]
fn setting_a_parameter_back_reports_restored_features() {
let mut s = Session::default();
@@ -79,7 +77,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(1), Some(1)), "{r}");
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{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}");
@@ -245,302 +243,3 @@ 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, &params, &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, &params, &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, &params, &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(&params, &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, &params, &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, &params, &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, &params, &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(&params[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(&params[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(&params[7]).unwrap().expr.clone(), s.doc.param(&params[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}");
}
-149
View File
@@ -34,71 +34,6 @@ 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, &params).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();
@@ -207,87 +142,3 @@ 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:?}");
}
-2
View File
@@ -25,8 +25,6 @@ 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);
+2 -2
View File
@@ -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); name?, body_name? (the new body)",
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
),
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().doc_only().params("→ rules with their expressions and values, and the active one"),
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.flat_pattern", "Create Flat Pattern", flat_pattern)
.at("SHEET METAL", "CREATE")
.icon("flat")
-195
View File
@@ -702,45 +702,6 @@ 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();
@@ -1597,159 +1558,3 @@ 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}");
}
}
}
+111 -233
View File
@@ -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::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with};
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
@@ -11,12 +11,9 @@ 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 {
@@ -106,7 +103,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; 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)",
"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)",
),
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
.at("SKETCH", "CONSTRAINTS")
@@ -173,11 +170,8 @@ 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).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.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.dimension_text", "Move Dimension Text", dimension_text)
.enabled(in_sketch)
@@ -225,143 +219,25 @@ 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);
// 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)?)
}
};
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(", "))));
}
sk.validate_periodic_splines()?;
*doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc;
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)))
}
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
}
/// How close (mm) a new point must land to an existing one to be connected to it.
@@ -1289,7 +1165,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
driven: false,
text: None,
}));
let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok();
let ok = solve_holding(&mut trial, &h).ok();
trial.constraints.retain(|c| c.id != TEMP);
if ok && !collapsed(sk, &trial) {
*sk = trial;
@@ -1364,7 +1240,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": report_json(&rep)}));
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
}
constrain(s, p, cmd, |sk| {
if let Some((a, b)) = line_points {
@@ -1610,7 +1486,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 auto_orientation(&ty, a, b, text_at) {
match ty.as_str() {
"angle" => {
let angle = (b - a).angle();
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
@@ -1647,7 +1523,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 auto_orientation(&ty, pa, pb, text_at) {
match ty.as_str() {
"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)),
@@ -1725,7 +1601,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
let e = value.as_ref().map(|e| doc.with_design_unit(e, if is_angle { Kind::Angle } else { Kind::Length })).unwrap_or(default_expr);
let e = value.clone().unwrap_or(default_expr);
let pname = doc.new_model_param(&e, unit);
// The value must evaluate.
let (vals, errs) = doc.param_values();
@@ -1767,28 +1643,6 @@ 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>) {
@@ -1969,82 +1823,109 @@ 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 id = target_sketch(s, p, cmd)?;
let (_, rep) = edit_solved(s, id, false, |sk, _| {
let (_, info) = edit(s, p, cmd, false, |sk, _| {
let q = point_ref(sk, p.get("point"), cmd, "point")?;
Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again()))
drag_entity(sk, Ent::Point(q), to, to);
Ok(())
})?;
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
Ok(json!({"sketch": info}))
}
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
drag_frame(s, p, false).map(|(v, _)| v)
}
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 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}`")))?;
// 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() }))
drag_entity(sk, e, from, to);
Ok(())
})?;
// 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 })))
Ok(json!({"sketch": info}))
}
/// 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, &params).unwrap_or_else(|e| {
s.doc = start.clone();
json!({"error": e.to_string()})
});
/// 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());
}
}
}
}
// 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")}));
let mut trial = sk.clone();
if solve_holding(&mut trial, &hold).ok() {
*sk = trial;
}
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).
@@ -2119,9 +2000,6 @@ 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;
+3 -7
View File
@@ -249,13 +249,9 @@ 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 (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})"))));
}
// Values of existing dimensions must be applied before judging degrees of freedom.
let (vals, _) = doc.param_values();
doc.apply_dimension_values(&vals, sk)?;
Ok(run_auto(sk, doc, p, datum))
})?;
Ok(json!({"added": added, "dof": dof, "sketch": info}))
-545
View File
@@ -1,545 +0,0 @@
//! 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))
}
@@ -1,290 +0,0 @@
//! 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());
}
+18 -11
View File
@@ -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, solve_current};
use super::sketch::{add_c, edit, ids_of};
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,9 +378,10 @@ 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 rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
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 ci = curve_arg(sk, p, cmd)?;
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
@@ -452,7 +453,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_with(sk, &hold, None, solvecraft_sketch::Analysis::Full, None).ok()
if !solvecraft_sketch::solve_holding(sk, &hold).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)
{
@@ -927,10 +928,11 @@ 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()?;
let rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
if !solvecraft_sketch::solve(sk).ok() {
return Err(bad(cmd, "solve existing constraints before offsetting"));
}
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<_>>()?;
@@ -1135,9 +1137,14 @@ 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 rep = solve_current(sk, doc)?;
if !rep.keeps_orientation() {
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
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"));
}
sk.validate_periodic_splines()?;
if let Some(association) = sk
@@ -499,30 +499,3 @@ 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}");
}
-716
View File
@@ -552,584 +552,6 @@ 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();
@@ -1247,141 +669,3 @@ 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}");
}
-154
View File
@@ -216,47 +216,6 @@ 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() {
@@ -313,116 +272,3 @@ 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]));
}
}
+7 -36
View File
@@ -64,8 +64,6 @@ pub struct Run {
pub unsupported: Vec<(String, String)>,
/// Steps whose generated parameters a command refused.
pub rejected: usize,
pub boolean_fallbacks: std::collections::BTreeMap<solvecraft_kernel::BooleanStrategy, usize>,
pub boolean_reports_truncated: bool,
}
impl Run {
@@ -184,7 +182,7 @@ fn base(r: &mut Rng) -> Step {
("sketch.line", json!({"points": pts})),
("sketch.arc.three_point", json!({"start": [0, h], "end": [0, 0], "through": [-bulge, h / 2.0]})),
("sketch.finish", json!({})),
("solid.extrude", json!({"distance": d})),
("Extrude", json!({"distance": d})),
],
)
}
@@ -198,7 +196,7 @@ fn base(r: &mut Rng) -> Step {
("sketch.create", json!({"plane": "XZ"})),
("sketch.rectangle.two_point", json!({"p0": [r0, 0], "p1": [r1, h]})),
("sketch.finish", json!({})),
("solid.revolve", json!({"axis": "Z", "angle": angle})),
("Revolve", json!({"axis": "Z", "angle": angle})),
],
)
}
@@ -291,15 +289,7 @@ pub fn run(seed: u64, max_steps: usize) -> Run {
pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) -> Run {
let mut r = Rng::new(seed);
let mut s = Session::default();
let mut run = Run {
seed,
steps: Vec::new(),
outcome: Outcome::ok(),
unsupported: Vec::new(),
rejected: 0,
boolean_fallbacks: Default::default(),
boolean_reports_truncated: false,
};
let mut run = Run { seed, steps: Vec::new(), outcome: Outcome::ok(), unsupported: Vec::new(), rejected: 0 };
let mut last_feature: Option<String> = None;
let n = 2 + r.below(max_steps.max(2) - 1);
let mut tries = 0;
@@ -324,12 +314,6 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
continue;
}
let kind = st.kind;
for result in s.model.results.iter().skip(before).filter(|result| result.error.is_none()) {
run.boolean_reports_truncated |= result.boolean_diagnostics.truncated;
for event in &result.boolean_diagnostics.events {
*run.boolean_fallbacks.entry(event.strategy).or_default() += 1;
}
}
run.steps.push(st);
if out.kind != "ok" {
run.outcome = out;
@@ -345,19 +329,14 @@ pub fn run_with(seed: u64, max_steps: usize, on_step: &mut dyn FnMut(&Step)) ->
/// Run fixed steps (a recorded or minimised design): the index and outcome of the first step
/// that isn't ok (or the last step's, ok).
pub fn replay(steps: &[Step]) -> (usize, Outcome) {
let (index, outcome, _) = replay_session(steps);
(index, outcome)
}
fn replay_session(steps: &[Step]) -> (usize, Outcome, Session) {
let mut s = Session::default();
for (i, st) in steps.iter().enumerate() {
let out = apply(&mut s, st);
if out.kind != "ok" {
return (i, out, s);
return (i, out);
}
}
(steps.len().saturating_sub(1), Outcome::ok(), s)
(steps.len().saturating_sub(1), Outcome::ok())
}
/// The start of a message, to tell failures apart (numbers vary between versions of a case).
@@ -388,14 +367,6 @@ pub fn minimise(run: &Run) -> Run {
}
}
}
let (_, outcome, session) = replay_session(&steps);
let mut boolean_fallbacks = std::collections::BTreeMap::new();
let mut boolean_reports_truncated = false;
for result in &session.model.results {
boolean_reports_truncated |= result.boolean_diagnostics.truncated;
for event in &result.boolean_diagnostics.events {
*boolean_fallbacks.entry(event.strategy).or_default() += 1;
}
}
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0, boolean_fallbacks, boolean_reports_truncated }
let (_, outcome) = replay(&steps);
Run { seed: run.seed, steps, outcome, unsupported: Vec::new(), rejected: 0 }
}
Loaded 100 of 498 files, more files were not shown because too many files have changed in this diff. Show more