mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 19:15:57 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6090e89b3f | |||
| f06b76e14f | |||
| 4b8872e4d0 | |||
| e7b5b2618e | |||
| ef9bb0db09 | |||
| f538eeb5fa | |||
| d470bfc52a | |||
| f82b6beedb | |||
| 80672460ed | |||
| 57e6396fbb | |||
| a5cc1243e3 | |||
| afcec4d0bd | |||
| 6efbd6ff14 | |||
| c51735c885 | |||
| 4eaadf3099 | |||
| 882ef0e9c6 | |||
| d15869ab85 | |||
| 511d105dcb | |||
| 2ebceb6918 | |||
| 7db66bb824 | |||
| a7e47fdae6 | |||
| ce226a88eb | |||
| 0b122e6d4a | |||
| b2fc6d1fac | |||
| e6a95615a0 | |||
| 6dab0fb2c1 | |||
| a04ef4ca59 | |||
| 8e87a37511 | |||
| fe4d171e22 | |||
| 75a5609b23 | |||
| fc8a5b21cf |
@@ -0,0 +1,29 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
(package(solvecraft-engine) & (
|
||||
test(=tests::hostile_params_never_panic)
|
||||
| test(/^tests::robustness::/)
|
||||
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
|
||||
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solvecraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solvecraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
# Every test's time in the log, so the slow ones are easy to find.
|
||||
status-level = "pass"
|
||||
final-status-level = "slow"
|
||||
failure-output = "immediate-final"
|
||||
slow-timeout = { period = "60s" }
|
||||
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
+26
-15
@@ -3,41 +3,52 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
|
||||
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
|
||||
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
|
||||
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
|
||||
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
|
||||
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
|
||||
**Renamed to SolidCraft.** Downloads, installers and programs now carry the SolidCraft name:
|
||||
the desktop app is `solidcraft`, the command line `solidcraft-cli`. `solvecraft-cli` is still
|
||||
installed as a deprecated alias that forwards to `solidcraft-cli` (it prints a one-line note on
|
||||
stderr), so scripts and MCP setups such as `claude mcp add solvecraft -- solvecraft-cli mcp`
|
||||
keep working; new setups use `claude mcp add solidcraft -- solidcraft-cli mcp`. Existing designs
|
||||
open as before. Upgrades: the `.msi` upgrades an earlier install in place (it now installs to
|
||||
`Program Files\SolidCraft`); the `solidcraft` `.deb`/`.rpm` replaces the old `solvecraft`
|
||||
package; the Flatpak app ID `ai.storyteller.solvecraft` is unchanged, so Flatpak updates
|
||||
continue; earlier AppImages still find updates.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
`-portable.zip` (x64, x86 and arm64).
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolveCraft.app` onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
|
||||
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
|
||||
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
|
||||
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
|
||||
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
|
||||
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
|
||||
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
|
||||
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
|
||||
|
||||
**Updating macOS from SolveCraft.app:** quit the app, rename the existing app in
|
||||
Applications to `SolidCraft.app`, then drag the new app from the DMG onto Applications
|
||||
and choose **Replace**. If both names are already installed, move the old `SolveCraft.app`
|
||||
to the Trash. Remove any Dock shortcut with the old name and pin the new app. Designs
|
||||
and settings are unchanged. The DMG includes these instructions.
|
||||
|
||||
For Flatpak feed installation and updates, add Flathub for the required runtime before
|
||||
installing SolidCraft; keep both remotes in the same user installation:
|
||||
|
||||
```sh
|
||||
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak remote-add --user --if-not-exists solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install --user solvecraft ai.storyteller.solvecraft
|
||||
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solvecraft
|
||||
flatpak run ai.storyteller.solvecraft
|
||||
```
|
||||
|
||||
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
|
||||
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
The builds aren't signed yet. On macOS, right-click `SolveCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
|
||||
[ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
|
||||
+91
-13
@@ -1,9 +1,15 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: "17 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cross_compile:
|
||||
@@ -16,7 +22,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Every push to main gets its full run; a new PR push replaces the old run.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -39,7 +46,7 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
native=false
|
||||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
@@ -92,12 +99,12 @@ jobs:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -113,15 +120,82 @@ jobs:
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Compile tests
|
||||
run: cargo test --workspace --profile ci --locked --no-run
|
||||
- name: Run tests
|
||||
# Every test binary at once (cargo test runs them one after another).
|
||||
run: cargo xtask test --locked
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- name: Compile tests into a nextest archive
|
||||
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
path: tests.tar.zst
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: build-tests
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
all=()
|
||||
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
|
||||
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
|
||||
# the build job's path, which is the same checkout path here.
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
path: target/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
# nextest does not run doc tests.
|
||||
- run: cargo test --workspace --profile ci --locked --doc
|
||||
|
||||
oracle:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
@@ -144,9 +218,9 @@ jobs:
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Build the command line
|
||||
run: cargo build -p solvecraft-cli --profile ci --locked
|
||||
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/solvecraft-cli
|
||||
run: cargo xtask oracle --cli target/ci/solidcraft-cli
|
||||
- name: Upload the report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
@@ -156,7 +230,7 @@ jobs:
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, tests, oracle]
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -166,14 +240,18 @@ 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
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solidcraft'"
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
@@ -91,10 +91,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
@@ -114,8 +114,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -135,17 +135,22 @@ jobs:
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
|
||||
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
|
||||
rpm -qip dist/release/*.rpm
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solvecraft
|
||||
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
|
||||
ldd target/release/solidcraft
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
# Legacy AppImage update alias (docs/compatibility.md).
|
||||
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
@@ -166,8 +171,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -181,7 +186,7 @@ jobs:
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
@@ -198,8 +203,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
@@ -211,7 +216,7 @@ jobs:
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
@@ -221,10 +226,10 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
@@ -237,8 +242,8 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -269,7 +274,7 @@ jobs:
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -290,7 +295,7 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -304,7 +309,7 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -115,7 +115,7 @@ version bumps solely from commit prefixes, overwrite published versions, or push
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
@@ -128,7 +128,7 @@ before the draft is verified.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
|
||||
@@ -92,7 +92,7 @@ version bumps solely from commit prefixes, overwrite published versions, or push
|
||||
before the draft is verified.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
|
||||
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
|
||||
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
|
||||
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
|
||||
@@ -105,7 +105,7 @@ before the draft is verified.
|
||||
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
|
||||
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
|
||||
values never come from a commercial CAD product.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
|
||||
+18
-5
@@ -17,23 +17,23 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo xtask test # unit, engine and UI-scenario tests
|
||||
cargo xtask ci # everything a PR must pass (see below)
|
||||
```
|
||||
|
||||
SolidCraft is the product and repository name. Cargo packages and executables retain
|
||||
their `solvecraft` names for compatibility with existing scripts and MCP clients.
|
||||
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
|
||||
`cargo build --release -p solvecraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
|
||||
Build the desktop app with `cargo build --release -p solidcraft`, or the CLI with
|
||||
`cargo build --release -p solidcraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-cli commands`
|
||||
after building). There is no Cargo package named `solidcraft`.
|
||||
|
||||
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
|
||||
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
|
||||
HTTPS or localhost, because it uses the Origin Private File System.
|
||||
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
@@ -153,6 +153,19 @@ 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`,
|
||||
|
||||
Generated
+16
-15
@@ -3289,8 +3289,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -3306,8 +3306,8 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.4.0"
|
||||
name = "solidcraft-cli"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3316,7 +3316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3331,7 +3331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3347,15 +3347,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
@@ -3367,7 +3368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3382,7 +3383,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
@@ -3390,7 +3391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
@@ -3400,7 +3401,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3411,7 +3412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3426,7 +3427,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4849,7 +4850,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -33,6 +33,9 @@ Cargo package names, settings/recovery locations and package IDs keep their exis
|
||||
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
|
||||
and filenames.
|
||||
|
||||
On macOS, the DMG contains `SolidCraft.app`. To replace an older `SolveCraft.app`
|
||||
without installing a second copy, follow the [macOS upgrade instructions](https://bherbruck.github.io/solidcraft/guide/getting-started.html#macos).
|
||||
|
||||
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
|
||||
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
|
||||
|
||||
@@ -97,22 +100,36 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solvecraft-cli -- commands # the command registry
|
||||
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft-cli -- commands # the command registry
|
||||
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
|
||||
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
|
||||
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
|
||||
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
|
||||
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
|
||||
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse.
|
||||
|
||||
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
|
||||
device. A udev rule that adds the `uaccess` tag does that, for example
|
||||
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
|
||||
|
||||
```
|
||||
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
|
||||
```
|
||||
|
||||
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
|
||||
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
|
||||
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
|
||||
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
|
||||
report descriptor, so they work as well.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
|
||||
+4
-2
@@ -9,7 +9,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
|
||||
| Reference tests | **75 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR; 75-sm-bracket-holes fails (#296). |
|
||||
| Reference tests | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
|
||||
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
|
||||
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
|
||||
@@ -44,7 +44,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| # | Milestone | Status | Hours left |
|
||||
|---|---|---|---|
|
||||
| M0 | Vertical slice | done | — |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
|
||||
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
|
||||
| M4 | Files: STEP import (done — [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 |
|
||||
@@ -137,6 +137,8 @@ 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
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
name = "solidcraft-cli"
|
||||
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
default-run = "solidcraft-cli"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
name = "solidcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
|
||||
# note on stderr. Shipped in every package for at least one release.
|
||||
[[bin]]
|
||||
name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-mcp = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
|
||||
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
|
||||
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
|
||||
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
|
||||
//! stays clean for MCP's JSON-RPC), and exits with its status.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::{Command, ExitCode};
|
||||
|
||||
const TARGET: &str = "solidcraft-cli";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
|
||||
let sibling = std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
|
||||
.filter(|p| p.is_file());
|
||||
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
|
||||
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
|
||||
Ok(status) => match status.code() {
|
||||
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
|
||||
None => ExitCode::FAILURE,
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//!
|
||||
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
|
||||
//! is recorded like any other failure.
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
//! SolveCraft headless command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solvecraft-cli commands the command registry as JSON
|
||||
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! solidcraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
|
||||
//! solidcraft-cli commands the command registry as JSON
|
||||
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
|
||||
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
|
||||
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolveCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -31,19 +31,19 @@ use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
solvecraft-cli commands
|
||||
solvecraft-cli recipe <recipe.json>
|
||||
solvecraft-cli oracle <case-dir>... [--json]
|
||||
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solvecraft-cli licences
|
||||
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
solidcraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solidcraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solidcraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solidcraft-cli exec <command> [json-params]
|
||||
solidcraft-cli commands
|
||||
solidcraft-cli recipe <recipe.json>
|
||||
solidcraft-cli oracle <case-dir>... [--json]
|
||||
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
|
||||
solidcraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -87,7 +87,7 @@ fn main() -> ExitCode {
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
@@ -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: solvecraft-cli save-stress <path> <rounds>".into());
|
||||
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
@@ -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: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
|
||||
};
|
||||
let v = read_json(path)?;
|
||||
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
|
||||
@@ -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: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
let t = std::time::Instant::now();
|
||||
let step = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
let commands = registry.as_array().unwrap();
|
||||
@@ -16,7 +16,7 @@ fn registry_output_is_complete_json() {
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
// Close our read end before waiting. No sleeps or timing threshold are needed:
|
||||
// the registry is larger than the pipe buffer, so it cannot all be written first.
|
||||
drop(child.stdout.take());
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
|
||||
@@ -15,14 +15,14 @@ fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_seed_runs_once_without_wrapping() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
@@ -12,7 +12,7 @@ fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_forwards_with_a_deprecation_note() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_alias_passes_the_exit_status_through() {
|
||||
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
|
||||
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
|
||||
assert_eq!(alias.status.code(), real.status.code());
|
||||
assert_eq!(alias.stdout, real.stdout);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::process::{Command, Stdio};
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
// A port that was free a moment ago: nothing listens on it.
|
||||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
|
||||
@@ -31,7 +31,7 @@ impl Drop for Scratch {
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
name = "solidcraft"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
default-run = "solidcraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -19,9 +19,11 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
quick-xml = { workspace = true }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -56,6 +56,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let rc = out.join("solidcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
|
||||
// Missing resource tools are a build error, rather than silently shipping a generic icon.
|
||||
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
|
||||
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod space_mouse;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
@@ -23,7 +23,7 @@ use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ impl eframe::App for App {
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.tick(&mut self.solve, ctx);
|
||||
if self.solve.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
@@ -155,11 +155,11 @@ fn main() -> eframe::Result {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
println!("usage: solidcraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
@@ -200,6 +200,8 @@ fn main() -> eframe::Result {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
// A saved anti-aliasing the device can't do: back to the default, with a notice.
|
||||
solvecraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -227,11 +229,11 @@ fn main() -> eframe::Result {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
|
||||
//! it publishes the latest axes, and the egui thread applies them once per frame.
|
||||
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
|
||||
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
|
||||
//! applies them once per frame.
|
||||
|
||||
mod hid_descriptor;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod hidraw;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hid_descriptor::Layout;
|
||||
#[cfg(target_os = "windows")]
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
@@ -208,9 +215,14 @@ impl Reading {
|
||||
}
|
||||
|
||||
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
|
||||
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
|
||||
matches!(vendor_id, 0x046d | 0x256f)
|
||||
}
|
||||
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
matches!(info.vendor_id(), 0x046d | 0x256f) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
|
||||
}
|
||||
|
||||
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
@@ -220,8 +232,18 @@ fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
|
||||
|
||||
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
|
||||
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
|
||||
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
|
||||
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
|
||||
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
Some(1) if layout == Layout::Combined => {
|
||||
let Some(body) = report.get(1..) else { return false };
|
||||
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
|
||||
reading.axes = Axes { translation, rotation };
|
||||
reading.translation_at = now;
|
||||
reading.rotation_at = now;
|
||||
true
|
||||
}
|
||||
Some(1) => {
|
||||
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
|
||||
reading.axes.translation = translation;
|
||||
@@ -238,6 +260,24 @@ fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Publishes one input report from the device to the egui thread.
|
||||
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(report, layout, &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The device is gone: stop reporting its last position.
|
||||
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
let Ok(mut api) = HidApi::new() else {
|
||||
log::warn!("3D mouse: HID initialization failed");
|
||||
@@ -247,27 +287,72 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
log::warn!("3D mouse: device scan failed: {error}");
|
||||
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
|
||||
let layout = device_layout(&device);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(&report[..size], &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
/// How the device packs its axes, from its report descriptor; the usual split layout if the
|
||||
/// descriptor cannot be read.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn device_layout(device: &hidapi::HidDevice) -> Layout {
|
||||
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
|
||||
match device.get_report_descriptor(&mut descriptor) {
|
||||
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
|
||||
Err(error) => {
|
||||
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
|
||||
Layout::Split
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
|
||||
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
|
||||
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
|
||||
use std::io::Read;
|
||||
let mut denied = None;
|
||||
loop {
|
||||
if let Some(device) = hidraw::find() {
|
||||
match std::fs::File::open(&device.path) {
|
||||
Ok(mut file) => {
|
||||
denied = None;
|
||||
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
|
||||
let mut report = [0_u8; 64];
|
||||
loop {
|
||||
match file.read(&mut report) {
|
||||
Ok(0) => break,
|
||||
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
reset(&ctx, &shared);
|
||||
}
|
||||
// Say so once per device node, not every retry.
|
||||
Err(error) if denied.as_ref() != Some(&device.path) => {
|
||||
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
|
||||
denied = Some(device.path);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
@@ -371,19 +456,46 @@ mod tests {
|
||||
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 2], &mut reading, now));
|
||||
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &mut reading, now));
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
|
||||
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
|
||||
let later = now + STALE_AFTER + Duration::from_millis(1);
|
||||
assert_eq!(reading.current(later), Axes::default());
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
|
||||
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
|
||||
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_report_one_carries_translation_and_rotation_together() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
|
||||
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
|
||||
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
|
||||
// The same bytes on a split device are only the translation report; its padding is not rotation.
|
||||
let mut split = Reading::new(now);
|
||||
assert!(decode_report(&report, Layout::Split, &mut split, now));
|
||||
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_layout_ignores_short_and_other_reports() {
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
// Extra padding after the six values is fine.
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
|
||||
let mut cam = Camera::default();
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
|
||||
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
|
||||
//! it simply yields less information.
|
||||
|
||||
/// Generic Desktop usage page, and the usages in it that matter here.
|
||||
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
|
||||
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
|
||||
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
|
||||
const AXIS_COUNT: u32 = 6;
|
||||
|
||||
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
|
||||
const MOTION_REPORT_ID: u8 = 1;
|
||||
|
||||
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
|
||||
const MAX_DESCRIPTOR_BYTES: usize = 4096;
|
||||
/// Bound on the usages one main item can declare through a usage range.
|
||||
const MAX_USAGES_PER_ITEM: u32 = 256;
|
||||
|
||||
/// How a device spreads its six motion axes over input reports.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Layout {
|
||||
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
|
||||
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
|
||||
#[default]
|
||||
Split,
|
||||
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
|
||||
Combined,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Item {
|
||||
/// 0 main, 1 global, 2 local (3 is reserved).
|
||||
kind: u8,
|
||||
tag: u8,
|
||||
value: u32,
|
||||
}
|
||||
|
||||
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
|
||||
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
|
||||
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
|
||||
let mut pos = 0_usize;
|
||||
std::iter::from_fn(move || {
|
||||
loop {
|
||||
let prefix = *descriptor.get(pos)?;
|
||||
pos += 1;
|
||||
if prefix == 0xFE {
|
||||
// Long item: a data length byte, a tag byte, then the data.
|
||||
let length = usize::from(*descriptor.get(pos)?);
|
||||
pos = pos.checked_add(2 + length)?;
|
||||
continue;
|
||||
}
|
||||
let size = match prefix & 0b11 {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2 => 2,
|
||||
_ => 4,
|
||||
};
|
||||
let data = descriptor.get(pos..pos + size)?;
|
||||
pos += size;
|
||||
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
|
||||
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Descriptor {
|
||||
multi_axis_controller: bool,
|
||||
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
|
||||
motion_report_axes: u8,
|
||||
}
|
||||
|
||||
impl Descriptor {
|
||||
pub fn parse(descriptor: &[u8]) -> Self {
|
||||
let mut summary = Self::default();
|
||||
let mut page = 0_u32;
|
||||
let mut report_id = 0_u8;
|
||||
let mut depth = 0_u32;
|
||||
// Local items belong to the next main item only.
|
||||
let mut usages: Vec<u32> = Vec::new();
|
||||
let mut usage_min = None;
|
||||
for item in items(descriptor) {
|
||||
match (item.kind, item.tag) {
|
||||
// Global: usage page, report id.
|
||||
(1, 0) => page = item.value,
|
||||
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
|
||||
// Local: usage, usage minimum, usage maximum.
|
||||
(2, 0) => usages.push(Self::full_usage(page, item)),
|
||||
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
|
||||
(2, 2) => {
|
||||
if let Some(min) = usage_min.take() {
|
||||
let max = Self::full_usage(page, item);
|
||||
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
|
||||
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
|
||||
}
|
||||
}
|
||||
// Main: input, collection, end collection.
|
||||
(0, 8) => {
|
||||
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
|
||||
summary.note_axes(&usages);
|
||||
}
|
||||
}
|
||||
(0, 0xA) => {
|
||||
// An application collection (1) at the top level names the device.
|
||||
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
|
||||
summary.multi_axis_controller = true;
|
||||
}
|
||||
depth += 1;
|
||||
}
|
||||
(0, 0xC) => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
if item.kind == 0 {
|
||||
usages.clear();
|
||||
usage_min = None;
|
||||
}
|
||||
}
|
||||
summary
|
||||
}
|
||||
|
||||
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
|
||||
fn full_usage(page: u32, item: Item) -> u32 {
|
||||
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
|
||||
}
|
||||
|
||||
fn note_axes(&mut self, usages: &[u32]) {
|
||||
for usage in usages {
|
||||
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
|
||||
if axis < AXIS_COUNT {
|
||||
self.motion_report_axes |= 1 << axis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
|
||||
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn is_multi_axis_controller(&self) -> bool {
|
||||
self.multi_axis_controller
|
||||
}
|
||||
|
||||
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
|
||||
pub fn layout(&self) -> Layout {
|
||||
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
|
||||
const SPLIT: &[u8] = &[
|
||||
0x05, 0x01, // usage page: generic desktop
|
||||
0x09, 0x08, // usage: multi-axis controller
|
||||
0xA1, 0x01, // collection: application
|
||||
0x85, 0x01, // report id 1
|
||||
0x16, 0xA2, 0xFE, // logical minimum -350
|
||||
0x26, 0x5E, 0x01, // logical maximum 350
|
||||
0x75, 0x10, // report size 16
|
||||
0x95, 0x03, // report count 3
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
|
||||
0x81, 0x06, // input: data, variable, relative
|
||||
0x85, 0x02, // report id 2
|
||||
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
|
||||
0x81, 0x06, // input
|
||||
0xC0, // end collection
|
||||
];
|
||||
|
||||
/// Report 1 = X Y Z Rx Ry Rz.
|
||||
const COMBINED: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
|
||||
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
|
||||
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
|
||||
const COMBINED_RANGE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
|
||||
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
|
||||
0x81, 0x06, 0xC0,
|
||||
];
|
||||
|
||||
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
|
||||
const KEYBOARD: &[u8] =
|
||||
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
|
||||
|
||||
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
|
||||
const MOUSE: &[u8] = &[
|
||||
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
|
||||
0x06, 0xC0, 0xC0,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
|
||||
let descriptor = Descriptor::parse(SPLIT);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0b000_111);
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_axes_in_report_one_are_a_combined_layout() {
|
||||
for bytes in [COMBINED, COMBINED_RANGE] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Combined);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_interfaces_are_not_3d_mice() {
|
||||
for bytes in [KEYBOARD, MOUSE, &[][..]] {
|
||||
let descriptor = Descriptor::parse(bytes);
|
||||
assert!(!descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.layout(), Layout::Split);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_byte_usages_carry_their_own_page() {
|
||||
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
|
||||
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
|
||||
assert!(descriptor.is_multi_axis_controller());
|
||||
assert_eq!(descriptor.motion_report_axes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_hostile_descriptors_never_panic() {
|
||||
for end in 0..=COMBINED.len() {
|
||||
let _ = Descriptor::parse(&COMBINED[..end]);
|
||||
}
|
||||
// Long item with a length running past the end, huge usage ranges, deep nesting.
|
||||
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
|
||||
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
|
||||
let _ = Descriptor::parse(&[0xC0; 64]);
|
||||
let _ = Descriptor::parse(&[0xA1; 64]);
|
||||
let mut state = 0x2545_F491_4F6C_DD1D_u64;
|
||||
for _ in 0..200 {
|
||||
let bytes: Vec<u8> = (0..64)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state.to_le_bytes()[0]
|
||||
})
|
||||
.collect();
|
||||
let _ = Descriptor::parse(&bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
|
||||
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
|
||||
//! plain file I/O, so no udev or hidapi library is needed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::hid_descriptor::{Descriptor, Layout};
|
||||
|
||||
const SYSFS: &str = "/sys/class/hidraw";
|
||||
const DEV: &str = "/dev";
|
||||
|
||||
pub struct Device {
|
||||
pub path: PathBuf,
|
||||
pub layout: Layout,
|
||||
}
|
||||
|
||||
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
|
||||
fn vendor_id(uevent: &str) -> Option<u16> {
|
||||
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
|
||||
let vendor = id.split(':').nth(1)?;
|
||||
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
|
||||
}
|
||||
|
||||
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
|
||||
pub fn find() -> Option<Device> {
|
||||
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
|
||||
names.sort();
|
||||
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
|
||||
}
|
||||
|
||||
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
|
||||
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
|
||||
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
|
||||
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_the_vendor_from_the_hid_id() {
|
||||
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
|
||||
assert_eq!(vendor_id(uevent), Some(0x256f));
|
||||
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ids_are_rejected() {
|
||||
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
|
||||
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1544,7 +1544,8 @@ pub struct Feature {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub suppressed: bool,
|
||||
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
|
||||
/// Names for the bodies this feature creates (in order); missing ones are numbered after the
|
||||
/// first (`Ribs (4)`), or get `BodyN` when there is none (or see `shell_piece_order`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub body_names: Vec<String>,
|
||||
/// Parameter names of the feature's inputs (see `FeatureKind::inputs`), e.g. `d3`.
|
||||
@@ -1570,6 +1571,8 @@ pub struct Feature {
|
||||
/// 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)]
|
||||
@@ -2181,6 +2184,28 @@ impl Document {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply parameter values to a sketch's dimensions and solve it. `stored` are the values
|
||||
/// its geometry is solved for (the stored sketch's): what the solve cannot reach in one
|
||||
/// jump it reaches by stepping the changed values from there, and an edit that would turn
|
||||
/// the sketch over is refused. Offsets whose sources moved are refreshed and solved again:
|
||||
/// from the stored state again after a refused or failed first solve (the geometry is still
|
||||
/// the stored one), from the solved one otherwise.
|
||||
pub fn solve_sketch(
|
||||
&self,
|
||||
vals: &std::collections::BTreeMap<String, expr::Value>,
|
||||
sk: &mut Sketch,
|
||||
stored: &solvecraft_sketch::DrivingValues,
|
||||
) -> Result<solvecraft_sketch::SolveReport> {
|
||||
self.apply_dimension_values(vals, sk)?;
|
||||
let mut report = solvecraft_sketch::solve_after_value_edit(sk, stored);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = if report.ok() { solvecraft_sketch::solve(sk) } else { solvecraft_sketch::solve_after_value_edit(sk, stored) };
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
/// Density of the default material (g/cm³), as Fusion's default.
|
||||
|
||||
+259
-55
@@ -7,7 +7,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solvecraft_kernel::{self as kernel, Body, BoolOp};
|
||||
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
|
||||
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
|
||||
|
||||
use crate::expr::{self, Kind, Value};
|
||||
use crate::lineage::Traced;
|
||||
@@ -262,7 +262,8 @@ pub struct ModelState {
|
||||
/// Cosmetic threads (from Thread features and threaded holes).
|
||||
pub threads: Vec<ThreadInfo>,
|
||||
pub bodies: Vec<ModelBody>,
|
||||
pub sketches: Vec<SolvedSketch>,
|
||||
/// Shared between states: a feature that does not change a sketch keeps it as it was.
|
||||
pub sketches: Vec<Arc<SolvedSketch>>,
|
||||
pub body_counter: usize,
|
||||
/// Sheet metal bodies (by body name) and their flat patterns.
|
||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||
@@ -270,6 +271,10 @@ pub struct ModelState {
|
||||
pub construct: Vec<crate::construct::Construct>,
|
||||
/// Evaluation-cache keys of the sketches (by sketch feature id).
|
||||
sketch_keys: BTreeMap<u64, u64>,
|
||||
/// The edges each fillet or chamfer blended, as found in this evaluation (by feature id):
|
||||
/// a pattern of the feature repeats these, not the points picked when it was made, which a
|
||||
/// parameter change may have moved off the edges.
|
||||
blend_edges: BTreeMap<u64, Vec<Vec3>>,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
@@ -289,13 +294,20 @@ impl ModelState {
|
||||
self.sketch_keys.get(&s.feature).hash(&mut h);
|
||||
}
|
||||
self.body_counter.hash(&mut h);
|
||||
for (feature, points) in &self.blend_edges {
|
||||
feature.hash(&mut h);
|
||||
for p in points {
|
||||
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
|
||||
/// made or changed, and sketches it made, take that key; the rest keep theirs.
|
||||
/// made or changed take that key, sketches it made a digest of their solved geometry (see
|
||||
/// [`quantize`]); the rest keep theirs.
|
||||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||||
let feature = f.id;
|
||||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||||
@@ -342,8 +354,16 @@ impl ModelState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.sketches.iter().any(|s| s.feature == feature) {
|
||||
self.sketch_keys.insert(feature, key);
|
||||
// Later features read a sketch's solved geometry: they are keyed by it (to the
|
||||
// quantum), so a value set back that solves to the same geometry reuses their results.
|
||||
if let Some(s) = self.sketches.iter().find(|s| s.feature == feature) {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h);
|
||||
let mut sketch = s.sketch.clone();
|
||||
quantize(&mut sketch);
|
||||
serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h);
|
||||
s.report.ok().hash(&mut h);
|
||||
self.sketch_keys.insert(feature, h.finish());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,7 +414,7 @@ impl ModelState {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
|
||||
self.sketches.iter().find(|s| s.feature == feature)
|
||||
self.sketches.iter().find(|s| s.feature == feature).map(|s| &**s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +428,11 @@ pub struct FeatureResult {
|
||||
pub boolean_diagnostics: kernel::BooleanDiagnostics,
|
||||
pub ms: f64,
|
||||
pub skipped: bool,
|
||||
/// Not evaluated on the document's current inputs: a feature at or after a sketch shown live
|
||||
/// ([`Model::show_live_sketch`]). It keeps its last result until the next `evaluate`, which
|
||||
/// never reuses it.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stale: bool,
|
||||
#[serde(skip)]
|
||||
fingerprint: u64,
|
||||
#[serde(skip)]
|
||||
@@ -448,7 +473,7 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
|
||||
prev.hash(&mut h);
|
||||
rolled_back.hash(&mut h);
|
||||
vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
|
||||
feature_json(f).hash(&mut h);
|
||||
for n in param_names(f) {
|
||||
n.hash(&mut h);
|
||||
match vals.get(&n) {
|
||||
@@ -463,6 +488,123 @@ fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_ba
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// Positions and radii are fingerprinted to this quantum (mm): far below anything a design
|
||||
/// can show, far above the solver's rounding, so a sketch solved again to the same geometry
|
||||
/// reads as the same input.
|
||||
const QUANTUM: f64 = 1e-6;
|
||||
|
||||
/// Put what a solve writes into a sketch on the [`QUANTUM`] grid: positions, radii and the
|
||||
/// measured values of driven dimensions.
|
||||
fn quantize(sk: &mut solvecraft_sketch::Sketch) {
|
||||
let q = |v: f64| (v / QUANTUM).round() * QUANTUM;
|
||||
for p in &mut sk.points {
|
||||
p.pos = Vec2::new(q(p.pos.x), q(p.pos.y));
|
||||
}
|
||||
for c in &mut sk.curves {
|
||||
if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind {
|
||||
*r = q(*r);
|
||||
}
|
||||
}
|
||||
for c in sk.constraints.iter_mut().filter(|c| c.driven) {
|
||||
if let Some(v) = c.kind.value() {
|
||||
c.kind.set_value(q(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized.
|
||||
fn feature_json(f: &Feature) -> String {
|
||||
if let FeatureKind::Sketch { .. } = &f.kind {
|
||||
let mut g = f.clone();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &mut g.kind {
|
||||
quantize(sketch);
|
||||
}
|
||||
return serde_json::to_string(&g).unwrap_or_default();
|
||||
}
|
||||
serde_json::to_string(f).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Store in `stored` the solution `solved` holds for it: the positions, the circle and ellipse
|
||||
/// radii, the arcs' end order and orientation (a signed sweep can swap them) and the
|
||||
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
|
||||
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
|
||||
/// match `stored` entity for entity is not adopted.
|
||||
fn adopt_solution(stored: &mut solvecraft_sketch::Sketch, solved: &solvecraft_sketch::Sketch) {
|
||||
if stored.points.len() != solved.points.len()
|
||||
|| stored.curves.len() != solved.curves.len()
|
||||
|| stored.constraints.len() != solved.constraints.len()
|
||||
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
|
||||
p.pos = q.pos;
|
||||
}
|
||||
for (c, d) in stored.curves.iter_mut().zip(&solved.curves) {
|
||||
match (&mut c.kind, &d.kind) {
|
||||
(CurveKind::Circle { r, .. }, CurveKind::Circle { r: s, .. }) | (CurveKind::Ellipse { r, .. }, CurveKind::Ellipse { r: s, .. }) => {
|
||||
*r = *s
|
||||
}
|
||||
(CurveKind::Arc { a, b, .. }, CurveKind::Arc { a: sa, b: sb, .. }) => {
|
||||
(*a, *b) = (*sa, *sb);
|
||||
c.reversed = d.reversed;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (c, d) in stored.constraints.iter_mut().zip(&solved.constraints) {
|
||||
if let Some(v) = d.kind.value() {
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The values features are evaluated with: the parameters, and the design's length unit.
|
||||
fn evaluation_values(doc: &Document) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
let (mut vals, perr) = doc.param_values();
|
||||
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
|
||||
&& scale != 1.0
|
||||
{
|
||||
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
|
||||
}
|
||||
(vals, perr)
|
||||
}
|
||||
|
||||
/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and
|
||||
/// the sheet metal or plastic rules it takes defaults from.
|
||||
fn chain_fingerprint(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, prev: u64, rolled_back: bool) -> u64 {
|
||||
let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.sheet.rules {
|
||||
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
// Plastic features take defaults from the plastic rules.
|
||||
if f.kind.is_plastic() && !doc.plastic.is_empty() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.plastic.rules {
|
||||
for (_, e, _) in r.fields() {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
fp
|
||||
}
|
||||
|
||||
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
|
||||
/// metal and plastic rules (and the parameters they use), components and body placement.
|
||||
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
|
||||
@@ -579,14 +721,79 @@ impl Model {
|
||||
self.results.iter().find(|r| r.id == id)
|
||||
}
|
||||
|
||||
/// Show a sketch's new solution before the model is evaluated again (the frames of an
|
||||
/// interactive drag): `sketch`, already solved with `report`, on the plane it was last
|
||||
/// evaluated on. The sketch's own result and the final state show it, and the sketch and
|
||||
/// every feature after it are marked stale, keeping their last geometry. Nothing else is
|
||||
/// recomputed; the next [`Model::evaluate`] recomputes from the sketch on. Returns `false`,
|
||||
/// changing nothing, when the sketch has no evaluated result or a feature before it is
|
||||
/// already stale (only a full evaluation brings that up to date).
|
||||
pub fn show_live_sketch(&mut self, feature: u64, sketch: solvecraft_sketch::Sketch, report: SolveReport) -> bool {
|
||||
let Some(i) = self.results.iter().position(|r| r.id == feature) else { return false };
|
||||
if self.results.iter().take(i).any(|r| r.stale) {
|
||||
return false;
|
||||
}
|
||||
let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else {
|
||||
return false;
|
||||
};
|
||||
let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report });
|
||||
let shown = self.results.iter().rposition(|r| !r.skipped);
|
||||
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
|
||||
r.stale = true;
|
||||
// The sketch's own state and the final one show it: only that entry changes (the
|
||||
// states are copied on the first frame; later frames own them and swap in place).
|
||||
if (k == i || Some(k) == shown)
|
||||
&& let Some(s) = Arc::make_mut(&mut r.state).sketches.iter_mut().find(|s| s.feature == feature)
|
||||
{
|
||||
*s = solved.clone();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Some results are stale ([`Model::show_live_sketch`]): the model needs an `evaluate`.
|
||||
pub fn is_stale(&self) -> bool {
|
||||
self.results.iter().any(|r| r.stale)
|
||||
}
|
||||
|
||||
/// Store in `doc` the sketches whose last evaluation solved them for new driving values
|
||||
/// (see `Document::solve_sketch`), so the stored sketch is the solved state the next value
|
||||
/// edit continues from (edits depend on their history, as a user editing dimensions one
|
||||
/// after another expects) and later evaluations repeat no continuation. Their results
|
||||
/// stand for the updated document: the solved sketch is what evaluating it gives.
|
||||
/// Returns how many sketches were stored.
|
||||
pub fn adopt_solved_sketches(&mut self, doc: &mut Document) -> usize {
|
||||
let solved: Vec<SolvedSketch> = self.edited_sketches(doc).cloned().collect();
|
||||
let Some(first) = solved.iter().filter_map(|s| doc.features.iter().position(|f| f.id == s.feature)).min() else { return 0 };
|
||||
for s in &solved {
|
||||
if let Ok(stored) = doc.sketch_mut(s.feature) {
|
||||
adopt_solution(stored, &s.sketch);
|
||||
}
|
||||
}
|
||||
// The fingerprints from the first stored sketch on.
|
||||
let vals = evaluation_values(doc).0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
|
||||
for (i, f) in doc.features.iter().enumerate().skip(first) {
|
||||
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker);
|
||||
if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) {
|
||||
r.fingerprint = prev_fp;
|
||||
}
|
||||
}
|
||||
solved.len()
|
||||
}
|
||||
|
||||
/// The sketches the last evaluation solved for driving values other than the ones `doc`
|
||||
/// stores them with (a value edit since they were stored).
|
||||
pub fn edited_sketches<'a>(&'a self, doc: &'a Document) -> impl Iterator<Item = &'a SolvedSketch> + 'a {
|
||||
self.results.iter().filter(|r| !r.skipped && r.error.is_none()).filter_map(|r| r.state.sketch(r.id)).filter(|s| {
|
||||
s.report.keeps_orientation() && doc.sketch(s.feature).is_ok_and(|stored| stored.driving_values() != s.sketch.driving_values())
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
let (mut vals, perr) = doc.param_values();
|
||||
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
|
||||
&& scale != 1.0
|
||||
{
|
||||
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
|
||||
}
|
||||
let (vals, perr) = evaluation_values(doc);
|
||||
self.param_errors = perr;
|
||||
let mut prev_fp = 0u64;
|
||||
let mut state = self.empty.clone();
|
||||
@@ -598,40 +805,13 @@ impl Model {
|
||||
let ctx = context_digest(doc, &vals);
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back) ^ placement_digest(doc, f);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.sheet.rules {
|
||||
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
// Plastic features take defaults from the plastic rules.
|
||||
if f.kind.is_plastic() && !doc.plastic.is_empty() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.plastic.rules {
|
||||
for (_, e, _) in r.fields() {
|
||||
for n in expr::references(e) {
|
||||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||||
}
|
||||
}
|
||||
}
|
||||
fp = h.finish();
|
||||
}
|
||||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
|
||||
prev_fp = fp;
|
||||
if reuse
|
||||
&& let Some(old) = self.results.get(i)
|
||||
&& old.id == f.id
|
||||
&& old.fingerprint == fp
|
||||
&& !old.stale
|
||||
{
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
@@ -649,6 +829,7 @@ impl Model {
|
||||
boolean_diagnostics: Default::default(),
|
||||
ms: 0.0,
|
||||
skipped: true,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
@@ -671,6 +852,7 @@ impl Model {
|
||||
boolean_diagnostics: c.boolean_diagnostics,
|
||||
ms: c.ms,
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
@@ -775,6 +957,7 @@ impl Model {
|
||||
boolean_diagnostics,
|
||||
ms,
|
||||
skipped: false,
|
||||
stale: false,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
@@ -871,6 +1054,16 @@ fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
{
|
||||
return n.clone();
|
||||
}
|
||||
// Bodies past the stored names (a profile added after the feature was made) are numbered
|
||||
// after the first name: `Ribs (4)` (#373). Features saved before format 14 keep `BodyN`,
|
||||
// which later features of those designs may name.
|
||||
if !f.shell_piece_order
|
||||
&& k >= f.body_names.len()
|
||||
&& let Some(base) = f.body_names.first().map(|n| n.trim()).filter(|n| !n.is_empty())
|
||||
{
|
||||
let n = format!("{base} ({})", k.saturating_add(1));
|
||||
return if state.body(&n).is_none() { n } else { unique_body_name(state, base) };
|
||||
}
|
||||
loop {
|
||||
state.body_counter += 1;
|
||||
let n = format!("Body{}", state.body_counter);
|
||||
@@ -2562,8 +2755,11 @@ fn replay(
|
||||
// A fillet or chamfer repeats at the copied edges.
|
||||
if let FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } = &src.kind {
|
||||
let r = val(vals, radius, Kind::Length)?;
|
||||
// The edges the feature blended in this evaluation (found by name), falling back to
|
||||
// its picked points.
|
||||
let found = st.blend_edges.get(&src.id).cloned().unwrap_or_else(|| edges.clone());
|
||||
for m in mats {
|
||||
let moved: Vec<Vec3> = edges.iter().map(|p| crate::apply_point(m, *p)).collect();
|
||||
let moved: Vec<Vec3> = found.iter().map(|p| crate::apply_point(m, *p)).collect();
|
||||
let ti = blend_target(st, &None, &moved)?;
|
||||
let Some(mb) = st.bodies.get(ti).cloned() else { continue };
|
||||
let nb = match &src.kind {
|
||||
@@ -2920,15 +3116,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let mut report = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
// Source dimensions may have moved curves during the solve.
|
||||
sk.refresh_offsets()?;
|
||||
report = solve(&mut sk);
|
||||
}
|
||||
if !report.ok() {
|
||||
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
// The stored sketch is solved for its stored values.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
@@ -2936,7 +3128,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
sk.validate_periodic_splines()?;
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Extrude { operation, targets, .. }
|
||||
@@ -3297,6 +3489,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
} else {
|
||||
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
|
||||
};
|
||||
st.blend_edges.insert(f.id, edges.clone());
|
||||
let input = mb.traced();
|
||||
// The blended body, and the lineage of what the last blend started from.
|
||||
let (nb, from) = match &f.kind {
|
||||
@@ -3580,6 +3773,12 @@ pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, fram
|
||||
}
|
||||
}
|
||||
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
|
||||
// Only a sketch the frame moves is copied.
|
||||
let maps_wire = |w: &solvecraft_sketch::Wire| !crate::is_identity(if w.space == solvecraft_sketch::WireSpace::Component { m } else { &inv });
|
||||
if crate::is_identity(m) && !s.sketch.wires.iter().any(maps_wire) {
|
||||
continue;
|
||||
}
|
||||
let s = Arc::make_mut(s);
|
||||
if !crate::is_identity(m)
|
||||
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
|
||||
{
|
||||
@@ -3646,7 +3845,12 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
}
|
||||
if primary {
|
||||
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
|
||||
let mut s2 = s.clone();
|
||||
if ident {
|
||||
// In place: shared as it is.
|
||||
out.sketches.push(s.clone());
|
||||
continue;
|
||||
}
|
||||
let mut s2 = SolvedSketch::clone(s);
|
||||
if !ident {
|
||||
let p = &s.plane;
|
||||
if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) {
|
||||
@@ -3658,7 +3862,7 @@ pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||||
map_wire(wire, &m);
|
||||
}
|
||||
}
|
||||
out.sketches.push(s2);
|
||||
out.sketches.push(Arc::new(s2));
|
||||
}
|
||||
for t in st
|
||||
.threads
|
||||
|
||||
@@ -687,7 +687,7 @@ pub(super) fn sheet_cut(
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
continue;
|
||||
}
|
||||
if let Some(bends) = sh.unmodeled_solid(&ss.plane)? {
|
||||
for bends in sh.unmodeled_solids(&ss.plane)? {
|
||||
for tool in tools {
|
||||
if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() {
|
||||
return Err(DocError::Invalid(
|
||||
|
||||
@@ -9,7 +9,13 @@ use crate::{DocError, Result};
|
||||
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
/// Boundary of the picked face as drawn: a closed surface cut into pieces (a cylinder or a
|
||||
/// round's torus in halves) projects as the whole patch, since its seams are hidden.
|
||||
pub(super) fn face_edges(mesh: &Mesh, face: u32, plane: &Plane) -> Result<Vec<usize>> {
|
||||
let patch = mesh.surface_patch(face as usize);
|
||||
if patch.len() > 1 {
|
||||
return family_edges(mesh, &patch.into_iter().collect(), plane);
|
||||
}
|
||||
let edges = mesh.face_edges(face).into_iter().filter(|e| !mesh.seams.get(*e).copied().unwrap_or(false)).collect();
|
||||
boundary_edges(mesh, edges, plane)
|
||||
}
|
||||
|
||||
+40
-7
@@ -879,10 +879,13 @@ impl SheetBody {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Actual curved material, used to certify unsupported folded machining before mutation.
|
||||
pub(crate) fn unmodeled_solid(&self, plane: &Plane) -> Result<Option<Body>> {
|
||||
/// Actual curved material, used to certify unsupported folded machining before mutation:
|
||||
/// one solid per bend zone or nonparallel panel. Each is sewn on its own, because the
|
||||
/// selected pieces need not form a manifold solid together (two bends meeting at an open
|
||||
/// corner relief touch only along an edge).
|
||||
pub(crate) fn unmodeled_solids(&self, plane: &Plane) -> Result<Vec<Body>> {
|
||||
if self.flat || self.flanges.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
let cells: Vec<_> = self
|
||||
@@ -893,10 +896,7 @@ impl SheetBody {
|
||||
Piece::Panel(pi) => apply_vector(&self.world(pi), Vec3::Z).cross(plane.normal()).len() > 1e-6,
|
||||
})
|
||||
.collect();
|
||||
if cells.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(kernel::sew(&self.faces(&cells, 0.0, self.t, &mut work)?)?))
|
||||
cells.iter().map(|c| Ok(kernel::sew(&self.faces(std::slice::from_ref(c), 0.0, self.t, &mut work)?)?)).collect()
|
||||
}
|
||||
|
||||
fn through_pocket_regions(&self, work: &mut FlatWork) -> Result<Vec<Region2>> {
|
||||
@@ -2250,4 +2250,37 @@ mod tests {
|
||||
s.pockets.push(pocket.clone());
|
||||
assert!(s.record_pocket(pocket.region, 0.0, 0.5, &mut work).is_err());
|
||||
}
|
||||
|
||||
/// A sketch solved again after a parameter change leaves its corners off by ~1e-13 mm, so
|
||||
/// its edges are not quite square to the axes. Such a tray (four 72° flanges, shrunk to
|
||||
/// 400 x 300 from a larger size) failed to unfold in the planar arrangement while the same
|
||||
/// tray drawn at that size unfolded.
|
||||
#[test]
|
||||
fn nearly_square_base_unfolds_like_the_exact_one() {
|
||||
let rv = RuleValues { thickness: 2.0, k_factor: 0.5, bend_radius: 1.0, ..steel() };
|
||||
let tray = |[a, b, c, d]: [Vec2; 4]| {
|
||||
let mut s = base_flange("Body1", "AL2", &rv, Plane::XY, rect(a, b, c, d));
|
||||
let picks = [Vec3::new(200.0, 0.0, 2.0), Vec3::new(400.0, 150.0, 2.0), Vec3::new(200.0, 300.0, 2.0), Vec3::new(0.0, 150.0, 2.0)];
|
||||
s.add_flanges(&picks, 38.6, 72f64.to_radians(), 1.0, "inside", false).unwrap();
|
||||
s
|
||||
};
|
||||
let flat_area = |s: &SheetBody| s.flat_regions().unwrap().iter().map(|r| r.area().abs()).sum::<f64>();
|
||||
let square = [Vec2::new(0.0, 0.0), Vec2::new(400.0, 0.0), Vec2::new(400.0, 300.0), Vec2::new(0.0, 300.0)];
|
||||
let exact = flat_area(&tray(square));
|
||||
// The corners the solver left after the parameter change, and the rectangle turned by a hair.
|
||||
let solved = [
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(400.0000000000001, 4.369543929613394e-13),
|
||||
Vec2::new(400.00000000000017, 300.0000000000008),
|
||||
Vec2::new(0.0, 300.0000000000009),
|
||||
];
|
||||
let (c, s) = (1e-14f64.cos(), 1e-14f64.sin());
|
||||
let turned = square.map(|p| Vec2::new(p.x * c - p.y * s, p.x * s + p.y * c));
|
||||
for corners in [solved, turned] {
|
||||
let mut sheet = tray(corners);
|
||||
assert!((flat_area(&sheet) - exact).abs() < 1e-6, "{} {exact}", flat_area(&sheet));
|
||||
sheet.flat = true;
|
||||
assert!((vol(&sheet.solid().unwrap()) - exact * 2.0).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("name?; camera: {target: [x,y,z], yaw, pitch, distance, fov?} (replaces a view of the same name)"),
|
||||
CommandSpec::new("view.rename", "Rename Named View", view_rename).params("view: name, name"),
|
||||
CommandSpec::new("view.delete", "Delete Named View", view_delete).params("view: name"),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo(),
|
||||
CommandSpec::new("view.list", "List Named Views", view_list).noundo().doc_only(),
|
||||
CommandSpec::new("browser.order", "Order Browser Items", order).params("folder, component?, order: [keys] (the folder's items in display order)"),
|
||||
CommandSpec::new("browser.visibility", "Show/Hide", visibility).icon("eye").noundo().params(
|
||||
"visible: bool; items?: [sketch id or name | body name | construction name | O|X|Y|Z|XY|XZ|YZ]; \
|
||||
@@ -312,6 +312,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// What the geometry reads: every feature's length inputs.
|
||||
let matched_configuration = s.doc.configuration_matches().then(|| s.doc.configs.active.clone()).flatten();
|
||||
let before = s.doc.length_inputs();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().units = u.to_string();
|
||||
// Unit-less user parameters holding a bare number read it in the design's units where they
|
||||
// stand for lengths. If any input moved, those numbers keep the old unit, written out.
|
||||
@@ -346,6 +347,7 @@ fn document_units(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(row) = matched_configuration {
|
||||
s.doc_mut().apply_configuration(&row)?;
|
||||
}
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({ "units": u, "kept": kept }))
|
||||
}
|
||||
|
||||
|
||||
@@ -393,8 +393,9 @@ fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("configuration `{r}` would fail to rebuild: {}", new_errors.join("; "))));
|
||||
}
|
||||
let styles = trial.doc.configuration_style_status(&trial.model.state()).map_err(|error| bad(cmd, format!("configuration `{r}`: {error}")))?;
|
||||
let solved = s.solved_sketches();
|
||||
s.doc = trial.doc;
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
let errors: Vec<String> = s.model.results.iter().filter_map(|x| x.error.clone()).collect();
|
||||
let mut out = show(s, p)?;
|
||||
out["errors"] = json!(errors);
|
||||
|
||||
@@ -19,8 +19,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "De
|
||||
/// Run another command's body inside this one (so the whole delete is one undo step).
|
||||
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
|
||||
let spec = super::find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(s).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(s, &p)?;
|
||||
let r = spec.run_nested(s, &p)?;
|
||||
s.refresh();
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use crate::params::{bad, bool_, num, str_, string_list};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(),
|
||||
CommandSpec::new("edit.undo", "Undo", undo).icon("undo").key("Ctrl+Z").noundo().doc_only(),
|
||||
CommandSpec::new("edit.redo", "Redo", redo).icon("redo").key("Ctrl+Y").noundo().doc_only(),
|
||||
CommandSpec::new("edit.checkpoint", "Checkpoint", checkpoint)
|
||||
.noundo()
|
||||
.params("→ {checkpoint: id}: remember the design, active sketch and undo history (restore with edit.restore_checkpoint)"),
|
||||
@@ -51,28 +51,35 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
|
||||
fn undo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) };
|
||||
let label = prev.label.clone();
|
||||
s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = prev.doc;
|
||||
s.active_sketch = prev.active_sketch;
|
||||
s.pending_moves = prev.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let now = go_to(s, prev);
|
||||
let label = now.label.clone();
|
||||
s.redo.push(now);
|
||||
Ok(json!({"undone": label}))
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) };
|
||||
let label = next.label.clone();
|
||||
s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() });
|
||||
s.doc = next.doc;
|
||||
s.active_sketch = next.active_sketch;
|
||||
s.pending_moves = next.pending_moves;
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let now = go_to(s, next);
|
||||
let label = now.label.clone();
|
||||
s.undo.push(now);
|
||||
Ok(json!({"redone": label}))
|
||||
}
|
||||
|
||||
/// Put the session at `snap`; returns where it was, under the same label.
|
||||
fn go_to(s: &mut Session, snap: Snapshot) -> Snapshot {
|
||||
let was = Snapshot { label: snap.label, doc: s.doc.clone(), active_sketch: s.active_sketch, pending_moves: s.pending_moves.clone() };
|
||||
let same = std::sync::Arc::ptr_eq(&s.doc, &snap.doc);
|
||||
s.doc = snap.doc;
|
||||
s.active_sketch = snap.active_sketch;
|
||||
s.pending_moves = snap.pending_moves;
|
||||
s.selection.clear();
|
||||
// A new document is evaluated by `execute`.
|
||||
if same {
|
||||
s.refresh();
|
||||
}
|
||||
was
|
||||
}
|
||||
|
||||
fn checkpoint(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(json!({"checkpoint": s.checkpoint()}))
|
||||
}
|
||||
@@ -477,8 +484,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
trial.doc_mut().marker = Some(idx + 1);
|
||||
trial.refresh();
|
||||
(spec.enabled)(&trial).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let r = (spec.run)(&mut trial, ¶ms)?;
|
||||
let r = spec.run_nested(&mut trial, ¶ms)?;
|
||||
let made = r.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
let mut new = trial.doc.feature(made).cloned().ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?;
|
||||
if let (FeatureKind::Emboss { face: Some(before), .. }, FeatureKind::Emboss { face: Some(after), .. }) = (&old.kind, &mut new.kind)
|
||||
|
||||
@@ -185,3 +185,47 @@ fn repeated_source_face_profile_requires_and_uses_the_exact_active_occurrence()
|
||||
assert!(!state.bodies.iter().any(|body| body.body.contains(Vec3::new(x, 5.0, 16.0))));
|
||||
}
|
||||
}
|
||||
|
||||
/// #307: four pairs of holes touching at one line each (a ring pattern against a grid). Every
|
||||
/// contact is the same up to rounding, so every hole is one face: 33 holes and 6 planes.
|
||||
#[test]
|
||||
fn tangent_hole_contacts_split_no_faces() {
|
||||
let recipe: Value = serde_json::from_str(RECIPE_307).unwrap();
|
||||
let mut s = Session::default();
|
||||
for c in recipe["commands"].as_array().unwrap() {
|
||||
s.execute(c["command"].as_str().unwrap(), &c["params"]).unwrap();
|
||||
}
|
||||
let m = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
let body = &m["bodies"][0];
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 33, "plane": 6}), "{m}");
|
||||
assert_eq!(body["faces"], 39, "{m}");
|
||||
}
|
||||
|
||||
const RECIPE_307: &str = r#"{"commands": [{"command": "sketch.create", "params": {"name": "Plate_sketch", "plane": "XY"}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l1"], "points": [[-50, -50], [50, -50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l2"], "points": ["l1.end", [50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l3"], "points": ["l2.end", [-50, 50]]}}, {"command": "sketch.line", "params": {"construction": false, "ids": ["l4"], "points": ["l3.end", "l1.start"]}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "distance": 5, "name": "Plate", "operation": "new", "profiles": [{"loops": [{"curves": ["l1", "l2", "l3", "l4"], "outer": true}], "sketch": "Plate_sketch"}], "sketch": "Plate_sketch"}}, {"command": "sketch.create", "params": {"name": "HoleA", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [-30, -30], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleA", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleA"}], "sketch": "HoleA", "through_all": true}}, {"command": "solid.pattern.rectangular", "params": {"count1": 5, "count2": 5, "dir1": [1, 0, 0], "dir2": [0, 1, 0], "features": ["HoleA"], "name": "GridPattern", "spacing1": 15, "spacing2": 15}}, {"command": "sketch.create", "params": {"name": "HoleB", "plane": "XY"}}, {"command": "sketch.circle.center", "params": {"center": [25, 0], "construction": false, "id": "c1", "radius": 5}}, {"command": "sketch.finish", "params": {}}, {"command": "solid.extrude", "params": {"body_names": ["Plate"], "direction": "positive", "name": "HoleB", "operation": "cut", "profiles": [{"loops": [{"curves": ["c1"], "outer": true}], "sketch": "HoleB"}], "sketch": "HoleB", "through_all": true}}, {"command": "solid.pattern.circular", "params": {"angle": 360, "axis": {"dir": [0, 0, 1], "origin": [0, 0, 0]}, "count": 8, "features": ["HoleB"], "name": "RingPattern"}}]}"#;
|
||||
|
||||
/// #308: a blind cut ending inside a cylinder leaves thin strips of the cylinder wall beside
|
||||
/// the cut; they must count as cylinder, not as a sphere or a freeform face.
|
||||
#[test]
|
||||
fn blind_cut_inside_a_cylinder_keeps_only_planes_and_cylinders() {
|
||||
let mut s = Session::default();
|
||||
let side = json!({"origin":[0,0,0],"x_dir":[0,0,-1],"y_dir":[0,1,0]});
|
||||
s.execute("solid.box", &json!({"length":40,"width":30,"height":3,"body_name":"Hinge"})).unwrap();
|
||||
for (name, radius, operation, extent) in
|
||||
[("Knuckle", 4, "join", json!({"distance":40})), ("Pin", 2, "cut", json!({"distance":100,"direction":"symmetric"}))]
|
||||
{
|
||||
s.execute("sketch.create", &json!({"name":name,"plane":side})).unwrap();
|
||||
s.execute("sketch.circle.center", &json!({"center":[-4,33],"radius":radius})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let mut p = json!({"sketch":name,"operation":operation,"body_names":["Hinge"]});
|
||||
p.as_object_mut().unwrap().extend(extent.as_object().unwrap().clone());
|
||||
s.execute("solid.extrude", &p).unwrap();
|
||||
}
|
||||
s.execute("sketch.create", &json!({"name":"Gap","plane":"XY"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0":[12,29],"p1":[28,40]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"sketch":"Gap","operation":"cut","distance":3.3,"body_names":["Hinge"]})).unwrap();
|
||||
let m = s.execute("inspect.measure", &json!({})).unwrap();
|
||||
let body = &m["bodies"][0];
|
||||
assert_eq!(body["face_types"], json!({"cylinder": 2, "plane": 12}), "{m}");
|
||||
assert_eq!(body["faces"], 14, "{m}");
|
||||
}
|
||||
|
||||
@@ -453,26 +453,15 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
s.active_sketch = None;
|
||||
s.refresh();
|
||||
// A feature that makes more bodies than it was given names (a multi-profile extrude with one
|
||||
// `body_name`) names the rest after the first: `Ribs`, `Ribs (2)`, `Ribs (3)`… (#266). The
|
||||
// names are stored on the feature, so recomputes and saved designs keep them.
|
||||
if let Some(base) = names.first().map(|n| n.trim().to_string()).filter(|n| !n.is_empty()) {
|
||||
let st = s.model.state();
|
||||
let made = st.bodies.iter().filter(|b| b.feature == id).count();
|
||||
if made > names.len() {
|
||||
let mut taken: std::collections::BTreeSet<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
taken.extend(names.iter().cloned());
|
||||
let mut i = 1usize;
|
||||
while names.len() < made {
|
||||
i += 1;
|
||||
let n = format!("{base} ({i})");
|
||||
if taken.insert(n.clone()) {
|
||||
names.push(n);
|
||||
}
|
||||
}
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.body_names = names;
|
||||
}
|
||||
s.refresh();
|
||||
// `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();
|
||||
|
||||
@@ -34,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list)
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).noundo().doc_only(),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ use serde_json::Value;
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
|
||||
/// A command's form for one frame of an interactive gesture: its result and the sketch it
|
||||
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
|
||||
/// evaluate as usual).
|
||||
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
|
||||
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
||||
|
||||
pub struct CommandSpec {
|
||||
@@ -74,11 +78,40 @@ pub struct CommandSpec {
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
pub undoable: bool,
|
||||
/// What runs instead of `run` when the command is given `live: true` (a frame of an
|
||||
/// interactive gesture, see [`Session::execute`]).
|
||||
pub live: Option<LiveRun>,
|
||||
/// Reads the evaluated model (the default). A command that reads only the document and the
|
||||
/// session's history runs on a model left stale by a live gesture without evaluating it first.
|
||||
pub reads_model: bool,
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
|
||||
CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true }
|
||||
CommandSpec {
|
||||
id,
|
||||
label,
|
||||
tab: "",
|
||||
panel: "",
|
||||
icon: "",
|
||||
shortcut: None,
|
||||
params: "",
|
||||
enabled: always,
|
||||
run,
|
||||
undoable: true,
|
||||
live: None,
|
||||
reads_model: true,
|
||||
}
|
||||
}
|
||||
/// Takes `live: true`, run by `run`.
|
||||
pub const fn live(mut self, run: LiveRun) -> Self {
|
||||
self.live = Some(run);
|
||||
self
|
||||
}
|
||||
/// Reads only the document (and history), never the evaluated model.
|
||||
pub const fn doc_only(mut self) -> Self {
|
||||
self.reads_model = false;
|
||||
self
|
||||
}
|
||||
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
|
||||
self.tab = tab;
|
||||
@@ -105,6 +138,12 @@ impl CommandSpec {
|
||||
self.undoable = false;
|
||||
self
|
||||
}
|
||||
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
|
||||
/// undo step and the evaluation).
|
||||
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
|
||||
(self.run)(s, p)
|
||||
}
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("joint")
|
||||
.params("occurrences: [ids or names] (two or more); name?"),
|
||||
CommandSpec::new("contact.edit", "Edit Contact Set", edit_set).params("set: id|name; occurrences?; name?; suppressed?: bool; delete?: bool"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.list", "List Contact Sets", list_sets).noundo().doc_only().params("→ sets, enabled, all"),
|
||||
CommandSpec::new("contact.check", "Check Contact", check).noundo().params("→ pairs in contact sets that pass through each other now"),
|
||||
CommandSpec::new("motion.study", "Motion Study", study).at("SOLID", "ASSEMBLE").icon("joint").params(
|
||||
"name?; study? (edit an existing one); steps? (timeline length, default 100); keys: [{joint, index?, points: [[step, value], …]}] \
|
||||
|
||||
@@ -15,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
favorite?: bool, new_name?: rename (references follow); or delete: name → recomputed: features rebuilt, \
|
||||
restored: features whose earlier result for the same inputs was reused (e.g. a value set back)",
|
||||
),
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().params(
|
||||
CommandSpec::new("parameters.list", "List Parameters", list).noundo().doc_only().params(
|
||||
"favorites?: bool (only favourites), filter?: all|user|sketch|feature|favorites → parameters (name, expression, unit, value in its unit, \
|
||||
source, feature/input, comment, favorite, uses, used_by, error) and cycles",
|
||||
),
|
||||
@@ -38,9 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("text: CSV (header with name, expression, unit?, comment?) or JSON [{name, expression, unit?, comment?}] | path; all or nothing"),
|
||||
CommandSpec::new("expr.evaluate", "Evaluate Expression", evaluate)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("expression, kind?: length|angle|unitless (or unit?: mm|in|deg…) → ok, value (mm/rad), display_value, references, error"),
|
||||
CommandSpec::new("parameters.complete", "Complete Expression", complete)
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("prefix?: the text before the cursor → word, suggestions [{text, kind: parameter|function|unit|constant, detail}]"),
|
||||
];
|
||||
|
||||
@@ -67,8 +69,10 @@ fn known(s: &Session, name: &str) -> bool {
|
||||
|
||||
fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.change";
|
||||
let solved = s.solved_sketches();
|
||||
if let Some(n) = str_(p, "delete") {
|
||||
s.doc_mut().remove_param(n.trim())?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
return Ok(json!({"deleted": n.trim()}));
|
||||
}
|
||||
let mut name = param_name(cmd, p)?.to_string();
|
||||
@@ -101,7 +105,7 @@ fn change_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
let (vals, _) = s.doc.param_values();
|
||||
out["name"] = json!(name);
|
||||
out["expression"] = json!(e);
|
||||
@@ -140,7 +144,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.add";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let e = expr_arg(p, "expression").or_else(|| expr_arg(p, "value")).ok_or_else(|| bad(cmd, "`expression` must be a number or text"))?;
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().add_user_param(&name, &e, str_(p, "unit"), str_(p, "comment"))?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
if let Some(f) = bool_(p, "favorite") {
|
||||
s.doc_mut().set_favorite(&name, f)?;
|
||||
}
|
||||
@@ -160,7 +166,9 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = param_name("parameters.delete", p)?.to_string();
|
||||
let solved = s.solved_sketches();
|
||||
s.doc_mut().remove_param(&name)?;
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"deleted": name}))
|
||||
}
|
||||
|
||||
@@ -238,8 +246,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `text` or `path`")),
|
||||
};
|
||||
let solved = s.solved_sketches();
|
||||
let names = s.doc_mut().import_params(&text)?;
|
||||
s.refresh();
|
||||
s.settle_value_edit(&solved)?;
|
||||
Ok(json!({"imported": names, "errors": failing(s)}))
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,11 @@ fn width_follows_an_expression() {
|
||||
assert!(e.contains("expected a length, got an angle"), "{e}");
|
||||
}
|
||||
|
||||
/// Setting a parameter back rebuilds nothing (the earlier results are reused) and says so: the
|
||||
/// features it restored are reported next to `recomputed`, so `recomputed: 0` is not mistaken
|
||||
/// for "nothing changed". timeline.edit reports the same.
|
||||
/// Setting a value back reuses the earlier results and says so: the features it restored are
|
||||
/// reported next to `recomputed`, so `recomputed: 0` is not mistaken for "nothing changed". A
|
||||
/// sketch dimension set back solves the sketch again from its current state (value edits
|
||||
/// continue from the last solved state); the features after it see the same geometry and are
|
||||
/// restored.
|
||||
#[test]
|
||||
fn setting_a_parameter_back_reports_restored_features() {
|
||||
let mut s = Session::default();
|
||||
@@ -77,7 +79,7 @@ fn setting_a_parameter_back_reports_restored_features() {
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "100 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(2), Some(0)), "{r}");
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "len", "expression": "60 mm"}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(0), Some(2)), "{r}");
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(1)), "{r}");
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
let r = run(&mut s, "timeline.edit", json!({"feature": "Extrude1", "set": {"extent": {"distance": "20"}}}));
|
||||
assert_eq!((r["recomputed"].as_u64(), r["restored"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
@@ -243,3 +245,302 @@ fn csv_and_json_round_trip() {
|
||||
assert_eq!(v.doc.param("len").map(|p| p.expr.as_str()), Some("80 mm"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Two triangles joined by three links (a b c, d e f; links a–d, b–e, c–f), a on the origin,
|
||||
/// a–b horizontal, every distance a dimension: the construction leaves it to the iteration.
|
||||
/// Returns the session, the sketch feature and the nine dimensions' parameters.
|
||||
fn linkage_session() -> (Session, u64, Vec<String>) {
|
||||
let layout = [[0.0, 0.0], [40.0, 0.0], [18.0, 30.0], [8.0, 55.0], [52.0, 48.0], [30.0, 80.0]];
|
||||
let pairs = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
|
||||
let ids = ["a", "b", "c", "d", "e", "f"];
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Linkage"}));
|
||||
for (id, at) in ids.iter().zip(layout) {
|
||||
run(&mut s, "sketch.point", json!({"point": at, "id": id}));
|
||||
}
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["a", "b"], "mode": "horizontal"}));
|
||||
let params = pairs
|
||||
.iter()
|
||||
.map(|&(i, j)| run(&mut s, "sketch.dimension", json!({"entities": [ids[i], ids[j]]}))["param"].as_str().unwrap_or_default().to_string())
|
||||
.collect();
|
||||
let feature = s.doc.find_feature("Linkage").map(|f| f.id).unwrap_or_default();
|
||||
(s, feature, params)
|
||||
}
|
||||
|
||||
/// Edited values the iteration cannot reach in one jump from the layout.
|
||||
const REACHED_BY_CONTINUATION: [f64; 9] = [
|
||||
49.75798473660107,
|
||||
44.249179252138724,
|
||||
28.66332553713371,
|
||||
49.725624597008775,
|
||||
45.81086166277159,
|
||||
35.887660702567544,
|
||||
45.64700450204954,
|
||||
50.756441510188495,
|
||||
63.72063313963373,
|
||||
];
|
||||
|
||||
/// Turn of both triangles in the evaluated sketch.
|
||||
fn linkage_turns(s: &Session, feature: u64) -> [bool; 2] {
|
||||
let state = s.model.state();
|
||||
let sk = &state.sketch(feature).unwrap().sketch;
|
||||
let p = |id: &str| sk.point(sk.resolve_point(id).unwrap()).unwrap();
|
||||
[(p("b") - p("a")).cross(p("c") - p("a")) > 0.0, (p("e") - p("d")).cross(p("f") - p("d")) > 0.0]
|
||||
}
|
||||
|
||||
/// Set the parameters in the document only (as an edit that does not store the sketch).
|
||||
fn set_params(s: &mut Session, params: &[String], values: &[f64]) {
|
||||
let changes: Vec<(String, String)> = params.iter().zip(values).map(|(name, value)| (name.clone(), format!("{value} mm"))).collect();
|
||||
s.doc_mut().change_params_atomic(&changes).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_edits_of_a_triconnected_linkage_continue_and_store_the_solved_sketch() {
|
||||
// The edit steps to the new values from the stored sketch, keeping both triangles' turn,
|
||||
// and stores what it reached: evaluating the document afresh needs no continuation.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let before = linkage_turns(&s, feature);
|
||||
// All nine at once: one at a time, some intermediate values have no solution.
|
||||
let batch: Vec<_> = params.iter().zip(REACHED_BY_CONTINUATION).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
|
||||
run(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
|
||||
let solved = s.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(solved.ok(), "{solved:?}");
|
||||
assert_eq!(linkage_turns(&s, feature), before);
|
||||
let mut fresh = solvecraft_doc::Model::default();
|
||||
fresh.evaluate(&s.doc);
|
||||
let again = fresh.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(again.ok() && again.continuations == 0, "{again:?}");
|
||||
// The kept results are what evaluating the stored document gives.
|
||||
let (kept, afresh) = (s.model.state(), fresh.state());
|
||||
let pairs = kept.sketch(feature).unwrap().sketch.points.iter().zip(&afresh.sketch(feature).unwrap().sketch.points);
|
||||
assert!(pairs.into_iter().all(|(a, b)| a.pos.dist(b.pos) < 1e-9));
|
||||
// In one go (one evaluation from the layout) it needs continuation.
|
||||
let (mut t, feature, params) = linkage_session();
|
||||
run(&mut t, "sketch.finish", json!({}));
|
||||
set_params(&mut t, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
t.refresh();
|
||||
let once = t.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(once.ok() && once.continuations > 0, "{once:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extending_after_a_parameter_edit_continues_from_the_stored_sketch() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 0], [110, 0]], "ids": ["ext"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[130, -20], [130, 20]], "ids": ["wall"]}));
|
||||
let before = linkage_turns(&s, feature);
|
||||
set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
run(&mut s, "sketch.extend", json!({"curve": "ext", "at": [110, 0]}));
|
||||
let solved = s.model.state().sketch(feature).unwrap().report.clone();
|
||||
assert!(solved.ok(), "{solved:?}");
|
||||
assert_eq!(linkage_turns(&s, feature), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_edit_keeps_the_stored_sketch_solved_for_its_values() {
|
||||
// Parameters no geometry satisfies, then an edit that does not insist on a solution: the
|
||||
// stored dimensions keep the values its geometry is solved for.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
let mut values = REACHED_BY_CONTINUATION;
|
||||
values[7] = 500.0;
|
||||
set_params(&mut s, ¶ms, &values);
|
||||
run(&mut s, "sketch.point", json!({"point": [200, 200]}));
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
for c in stored.constraints.iter().filter(|c| c.param.is_some()) {
|
||||
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
|
||||
assert!((measured - c.kind.value().unwrap()).abs() < 1e-6, "{}: {measured} vs {:?}", c.id, c.kind.value());
|
||||
}
|
||||
}
|
||||
|
||||
/// Values the linkage reaches only with its top triangle mirrored.
|
||||
const FLIPPING: [f64; 9] = [
|
||||
26.483003374917082,
|
||||
90.0253808040292,
|
||||
84.67215428223084,
|
||||
54.580784880976275,
|
||||
68.12871394439186,
|
||||
30.98508537669166,
|
||||
116.94895853350752,
|
||||
103.75535113133466,
|
||||
32.83103173640168,
|
||||
];
|
||||
|
||||
/// All nine parameters set by one command.
|
||||
fn import_values(params: &[String], values: &[f64]) -> Value {
|
||||
let batch: Vec<_> = params.iter().zip(values).map(|(n, v)| json!({"name": n, "expression": format!("{v} mm")})).collect();
|
||||
json!({"text": serde_json::to_string(&batch).unwrap()})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_would_flip_the_sketch_is_refused_and_named() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (points, before) = (stored_points(&s, feature), params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>());
|
||||
let why = err(&mut s, "parameters.import", import_values(¶ms, &FLIPPING));
|
||||
assert!(why.contains("Linkage") && why.contains("flip the sketch over"), "{why}");
|
||||
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
assert!(s.model.state().sketch(feature).unwrap().report.keeps_orientation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_design_stored_with_other_values_still_rebuilds() {
|
||||
// A design whose stored sketch is not solved for its parameters (saved before value edits
|
||||
// stored their solve): evaluating it never refuses. The flipped solve is kept, with a
|
||||
// warning, the flipped constraints listed as conflicts, and the solid built from it.
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
for line in [[[8.0, 55.0], [52.0, 48.0]], [[52.0, 48.0], [30.0, 80.0]], [[30.0, 80.0], [8.0, 55.0]]] {
|
||||
run(&mut s, "sketch.line", json!({"points": line}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Linkage", "distance": 5}));
|
||||
set_params(&mut s, ¶ms, &FLIPPING);
|
||||
let s = Session::new(solvecraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let state = s.model.state();
|
||||
let solved = state.sketch(feature).unwrap();
|
||||
assert!(solved.report.ok() && !solved.report.folded.is_empty(), "{:?}", solved.report);
|
||||
let warning = s.model.result(feature).and_then(|r| r.warning.clone()).unwrap_or_default();
|
||||
assert!(warning.contains("flip the sketch over") && solved.report.folded.iter().all(|id| warning.contains(id.as_str())), "{warning}");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
// Listed as conflicts wherever conflicts are shown.
|
||||
let mut s = s;
|
||||
s.active_sketch = Some(feature);
|
||||
let si = run(&mut s, "sketch.inspect", json!({}));
|
||||
let glyphs = run(&mut s, "sketch.glyphs", json!({}));
|
||||
for id in &solved.report.folded {
|
||||
assert!(si["failing"].as_array().unwrap().contains(&json!(id)), "{si}");
|
||||
assert!(glyphs["conflicts"].as_array().unwrap().contains(&json!(id)), "{glyphs}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_breaks_a_projecting_sketch_downstream_applies_with_a_warning() {
|
||||
// `w` places a guide line; a second sketch projects it and ends a 12 mm line on it. Moving
|
||||
// the guide out of reach is the guide sketch's edit, not a value edit of the projecting
|
||||
// sketch: it applies, and the projecting sketch carries a warning.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "w", "expression": "10 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Guide"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, -30], [10, 30]], "ids": ["guide"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "guide", "mode": "vertical"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "guide.start"], "type": "horizontal", "value": "w"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let id = run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Reach"}))["sketch"].as_u64().unwrap();
|
||||
let projected = run(&mut s, "sketch.project", json!({"refs": [{"sketch": "Guide", "curve": "guide"}]}))["links"][0]["curves"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let line = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -(44.0_f64).sqrt()]]}))["curves"][0].as_str().unwrap().to_string();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [line], "value": 12}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": projected}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().sketch(id).unwrap().report.ok());
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "w", "expression": "20 mm"}));
|
||||
assert_eq!(r["value"], json!(20.0), "the edit applies: {r}");
|
||||
assert!(!s.model.state().sketch(id).unwrap().report.ok());
|
||||
assert!(s.model.result(id).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("conflict")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_auto_constrain_keeps_the_stored_sketch_consistent() {
|
||||
// Auto-constrain works on the current values: reached by continuation, its result is
|
||||
// stored solved for them; values no geometry satisfies refuse it and change nothing.
|
||||
let consistent = |s: &Session, feature: u64| {
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
stored.constraints.iter().filter(|c| c.param.is_some()).all(|c| {
|
||||
let measured = solvecraft_sketch::measure_dimension(stored, &c.kind).unwrap();
|
||||
(measured - c.kind.value().unwrap()).abs() < 1e-6
|
||||
})
|
||||
};
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
let mut values = REACHED_BY_CONTINUATION;
|
||||
values[7] = 500.0;
|
||||
set_params(&mut s, ¶ms, &values);
|
||||
let before = s.doc.sketch(feature).unwrap().clone();
|
||||
let why = err(&mut s, "sketch.auto_constrain", json!({}));
|
||||
assert!(why.contains("conflict"), "{why}");
|
||||
assert_eq!(*s.doc.sketch(feature).unwrap(), before);
|
||||
assert!(consistent(&s, feature));
|
||||
set_params(&mut s, ¶ms, &REACHED_BY_CONTINUATION);
|
||||
run(&mut s, "sketch.auto_constrain", json!({}));
|
||||
assert!(consistent(&s, feature));
|
||||
let stored = s.doc.sketch(feature).unwrap();
|
||||
for (c, v) in stored.constraints.iter().filter(|c| c.param.is_some()).zip(REACHED_BY_CONTINUATION) {
|
||||
assert!((c.kind.value().unwrap() - v).abs() < 1e-9, "{}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored sketch's points, by id.
|
||||
fn stored_points(s: &Session, feature: u64) -> Vec<solvecraft_geom::Vec2> {
|
||||
s.doc.sketch(feature).unwrap().points.iter().map(|p| p.pos).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsolvable_value_edit_is_refused_and_changes_nothing() {
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let (expr, points) = (s.doc.param(¶ms[7]).map(|p| p.expr.clone()), stored_points(&s, feature));
|
||||
let why = err(&mut s, "parameters.change", json!({"name": params[7], "expression": "500 mm"}));
|
||||
assert!(why.contains("Linkage") && why.contains("conflict"), "{why}");
|
||||
assert_eq!(s.doc.param(¶ms[7]).map(|p| p.expr.clone()), expr);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
assert!(s.model.state().sketch(feature).unwrap().report.ok());
|
||||
// A batch with one such value: none of it.
|
||||
let before: Vec<_> = params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect();
|
||||
let batch: Vec<_> = params
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| json!({"name": n, "expression": if i == 7 { "500 mm".to_string() } else { format!("{} mm", REACHED_BY_CONTINUATION[i]) }}))
|
||||
.collect();
|
||||
let why = err(&mut s, "parameters.import", json!({"text": serde_json::to_string(&batch).unwrap()}));
|
||||
assert!(why.contains("Linkage"), "{why}");
|
||||
assert_eq!(params.iter().map(|n| s.doc.param(n).map(|p| p.expr.clone())).collect::<Vec<_>>(), before);
|
||||
assert_eq!(stored_points(&s, feature), points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_edits_continue_from_the_last_solved_state() {
|
||||
// A → B → C, each a small edit solved in one jump; C is solved from B (the stored state),
|
||||
// and setting both values back reproduces A (the linkage is fully constrained).
|
||||
let close = |a: &[solvecraft_geom::Vec2], b: &[solvecraft_geom::Vec2], tol: f64| a.iter().zip(b).all(|(p, q)| p.dist(*q) < tol);
|
||||
let (mut s, feature, params) = linkage_session();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let a = stored_points(&s, feature);
|
||||
let (p7, p8) = (s.doc.param(¶ms[7]).unwrap().expr.clone(), s.doc.param(¶ms[8]).unwrap().expr.clone());
|
||||
run(&mut s, "parameters.change", json!({"name": params[8], "expression": "53 mm"}));
|
||||
let b_sketch = s.doc.sketch(feature).unwrap().clone();
|
||||
let b = stored_points(&s, feature);
|
||||
assert!(!close(&a, &b, 1e-3) && s.model.state().sketch(feature).unwrap().report.continuations == 0);
|
||||
run(&mut s, "parameters.change", json!({"name": params[7], "expression": "50 mm"}));
|
||||
let c = stored_points(&s, feature);
|
||||
// The same edit solved from B directly.
|
||||
let mut from_b = b_sketch.clone();
|
||||
let stored = from_b.driving_values();
|
||||
let (vals, _) = s.doc.param_values();
|
||||
s.doc.apply_dimension_values(&vals, &mut from_b).unwrap();
|
||||
assert!(solvecraft_sketch::solve_after_value_edit(&mut from_b, &stored).ok());
|
||||
assert!(close(&c, &from_b.points.iter().map(|p| p.pos).collect::<Vec<_>>(), 1e-6) && !close(&c, &b, 1e-3));
|
||||
run(&mut s, "parameters.change", json!({"name": params[7], "expression": p7}));
|
||||
run(&mut s, "parameters.change", json!({"name": params[8], "expression": p8}));
|
||||
assert!(close(&stored_points(&s, feature), &a, 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_configurations_back_reuses_the_later_features() {
|
||||
// Rows A and B of `len`: back on A, the sketch solves again from B's state to A's
|
||||
// geometry, and the extrude after it is restored from the cache.
|
||||
let mut s = Session::default();
|
||||
block(&mut s);
|
||||
run(&mut s, "config.configure", json!({"params": ["len"]}));
|
||||
run(&mut s, "config.row", json!({"name": "A", "values": {"len": "60 mm"}}));
|
||||
run(&mut s, "config.row", json!({"name": "B", "values": {"len": "100 mm"}}));
|
||||
run(&mut s, "config.activate", json!({"row": "B"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 50.0 * 10.0) < 1e-9);
|
||||
let hits = |s: &Session| s.model.cache.lock().map(|c| c.hits).unwrap_or(0);
|
||||
let before = hits(&s);
|
||||
let r = run(&mut s, "config.activate", json!({"row": "A"}));
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
assert!(hits(&s) > before, "{r}");
|
||||
}
|
||||
|
||||
@@ -252,3 +252,42 @@ fn pattern_of_512_hole_cuts_finishes_in_reasonable_time() {
|
||||
// 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:?}");
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sheet.manage_rules", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("sheet_rules").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().doc_only().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("sheet.flat_pattern", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
|
||||
@@ -702,6 +702,45 @@ fn fold_keeps_the_larger_side_either_way() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A tray shrunk by a parameter change unfolds like the same tray built at that size. The
|
||||
/// sketch solved again leaves its corners ~1e-13 mm off, and the flat pattern's planar
|
||||
/// arrangement used to drop a bend zone through a gap that small: "planar region arrangement
|
||||
/// failed", for W alone or both sizes, at 72° or 90°, while growing the tray worked.
|
||||
#[test]
|
||||
fn tray_shrunk_by_a_parameter_unfolds_like_one_built_at_that_size() {
|
||||
for angle in [72.0, 90.0] {
|
||||
let tray = |s: &mut Session, w: f64, h: f64| {
|
||||
run(s, "parameters.add", json!({"name": "W", "expression": format!("{w} mm")}));
|
||||
run(s, "parameters.add", json!({"name": "H", "expression": format!("{h} mm")}));
|
||||
run(s, "sheet.manage_rules", json!({"name": "AL2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Tray"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [w, h]}));
|
||||
run(s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
|
||||
run(s, "sketch.dimension", json!({"entities": ["l2"], "value": "H"}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"type": "base", "sketch": "Tray"}));
|
||||
let edges = json!([[w / 2.0, 0, 2], [w, h / 2.0, 2], [w / 2.0, h, 2], [0, h / 2.0, 2]]);
|
||||
run(s, "sheet.flange", json!({"type": "edge", "edges": edges, "height": 38.6, "angle": angle}));
|
||||
run(s, "sheet.unfold", json!({}));
|
||||
};
|
||||
let mut direct = Session::default();
|
||||
tray(&mut direct, 400.0, 300.0);
|
||||
let want = flat(&mut direct)["flat_size_mm"].clone();
|
||||
let mut shrunk = Session::default();
|
||||
tray(&mut shrunk, 2000.0, 2500.0);
|
||||
for (name, expr) in [("W", "400 mm"), ("H", "300 mm")] {
|
||||
let r = run(&mut shrunk, "parameters.change", json!({"name": name, "expression": expr}));
|
||||
assert_eq!(r["errors"], json!([]), "{angle}° {name} = {expr}: {r}");
|
||||
}
|
||||
let got = flat(&mut shrunk)["flat_size_mm"].clone();
|
||||
for k in 0..3 {
|
||||
assert!((got[k].as_f64().unwrap() - want[k].as_f64().unwrap()).abs() < 1e-9, "{angle}°: {got} {want}");
|
||||
}
|
||||
assert!(rel(volume(&mut shrunk), volume(&mut direct)) < 1e-9, "{angle}°");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bend_table_exports_shop_dimensions_and_tracks_parameter_changes() {
|
||||
let mut s = Session::default();
|
||||
@@ -1646,3 +1685,71 @@ fn fold_at_a_tab_root_names_the_real_cause() {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+237
-115
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::{FeatureKind, PlaneRef, expr::Kind};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::{ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_holding};
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, SolveReport, solve, solve_with};
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3};
|
||||
@@ -11,9 +11,12 @@ use crate::{EngineError, Result, Session};
|
||||
|
||||
#[path = "sketch_dimensioned.rs"]
|
||||
mod dimensioned;
|
||||
#[path = "sketch_drag.rs"]
|
||||
mod drag;
|
||||
#[path = "sketch_face.rs"]
|
||||
pub(super) mod face;
|
||||
use dimensioned::draw as draw_dimensioned;
|
||||
use drag::drag_entity;
|
||||
|
||||
/// The `connect` parameter every drawing command takes (see `connect_points`).
|
||||
macro_rules! connect_doc {
|
||||
@@ -103,7 +106,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y], id?", connect_doc!(), created_doc!())),
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
"entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. Returns measured in unit (mm or deg)",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
@@ -170,8 +173,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve, point or constraint ids]"),
|
||||
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"),
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y]: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can",
|
||||
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).enabled(in_sketch).live(drag_live).params(
|
||||
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
|
||||
),
|
||||
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).enabled(in_sketch).doc_only().params(
|
||||
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
|
||||
),
|
||||
CommandSpec::new("sketch.dimension_text", "Move Dimension Text", dimension_text)
|
||||
.enabled(in_sketch)
|
||||
@@ -219,25 +225,143 @@ pub(super) fn edit<T>(
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<T>,
|
||||
) -> Result<(T, Value)> {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let (out, rep) = edit_solved(s, id, strict, |sk, doc| Ok((f(sk, doc)?, Left::Unsolved)))?;
|
||||
Ok((out, rep.map(|rep| report_json(&rep)).unwrap_or(Value::Null)))
|
||||
}
|
||||
|
||||
/// A solve's outcome as commands return it.
|
||||
pub(super) fn report_json(rep: &SolveReport) -> Value {
|
||||
json!({"solved": rep.keeps_orientation(), "dof": rep.dof})
|
||||
}
|
||||
|
||||
/// What an [`edit_solved`] step leaves for the edit's own solve.
|
||||
pub(super) enum Left {
|
||||
/// The sketch, solved from where it is.
|
||||
Unsolved,
|
||||
/// The sketch, solved keeping the orientation of these positions (the last solved ones,
|
||||
/// before a drag left a step placed but unsolved).
|
||||
MovedFrom(Vec<Vec2>),
|
||||
/// The sketch, solved with the dimensions' values (from the parameters) applied.
|
||||
Solved,
|
||||
}
|
||||
|
||||
impl Left {
|
||||
/// Solved again by the edit even when already solved (for its report).
|
||||
pub(super) fn solve_again(self) -> Left {
|
||||
match self {
|
||||
Left::Solved => Left::Unsolved,
|
||||
left => left,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `edit` of sketch `id` for a step that may solve the sketch itself: when it returns
|
||||
/// [`Left::Solved`] (and offsets need not be recomputed) the sketch is not solved again and the
|
||||
/// report is `None`.
|
||||
pub(super) fn edit_solved<T>(
|
||||
s: &mut Session,
|
||||
id: u64,
|
||||
strict: bool,
|
||||
f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result<(T, Left)>,
|
||||
) -> Result<(T, Option<SolveReport>)> {
|
||||
let mut doc = (*s.doc).clone();
|
||||
let mut sk = doc.sketch(id)?.clone();
|
||||
super::sketch_project::refresh(s, &doc, id, &mut sk);
|
||||
let out = f(&mut sk, &mut doc)?;
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, &mut sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
let mut rep = solve(&mut sk);
|
||||
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
|
||||
sk.refresh_offsets()?;
|
||||
rep = solve(&mut sk);
|
||||
}
|
||||
if strict && !rep.ok() {
|
||||
return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", "))));
|
||||
}
|
||||
// The stored sketch is solved for its stored values.
|
||||
let stored = sk.driving_values();
|
||||
let (out, left) = f(&mut sk, &mut doc)?;
|
||||
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
|
||||
let rep = match left {
|
||||
// A drag step solved it (held, with the dimensions' values applied).
|
||||
Left::Solved if !offsets => None,
|
||||
// A drag left a step placed but unsolved: solve it keeping the orientation of the last
|
||||
// solved positions. A drag changes no values, so there is nothing to continue.
|
||||
Left::MovedFrom(reference) => {
|
||||
doc.apply_dimension_values(&doc.param_values().0, &mut sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
let unsolved = sk.clone();
|
||||
let mut rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
|
||||
if offsets {
|
||||
sk.refresh_offsets()?;
|
||||
rep = counted_solve(&mut sk, &Hold::default(), Some(&reference), Analysis::Full, None);
|
||||
}
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
// Solved for the document's values, continuing from the stored solved state.
|
||||
Left::Solved | Left::Unsolved => {
|
||||
let (vals, _) = doc.param_values();
|
||||
sk.refresh_offsets()?;
|
||||
let unsolved = sk.clone();
|
||||
#[cfg(test)]
|
||||
solve_counts::record(Analysis::Full);
|
||||
let rep = doc.solve_sketch(&vals, &mut sk, &stored)?;
|
||||
Some(settle(strict, rep, &mut sk, unsolved)?)
|
||||
}
|
||||
};
|
||||
sk.validate_periodic_splines()?;
|
||||
*doc.sketch_mut(id)? = sk;
|
||||
*s.doc_mut() = doc;
|
||||
Ok((out, json!({"solved": rep.ok(), "dof": rep.dof})))
|
||||
Ok((out, rep))
|
||||
}
|
||||
|
||||
/// An edit's solve outcome: with `strict`, a solve that fails or turns the sketch over is an
|
||||
/// error; otherwise such a sketch is stored as the edit left it before the solve, its values
|
||||
/// and geometry together, so the stored sketch stays solved for its stored values.
|
||||
fn settle(strict: bool, rep: SolveReport, sk: &mut Sketch, unsolved: Sketch) -> Result<SolveReport> {
|
||||
if strict && !rep.keeps_orientation() {
|
||||
return Err(EngineError::Other(rep.failure(|f| format!("that would over-constrain the sketch (conflicts with {f})"))));
|
||||
}
|
||||
if !rep.keeps_orientation() {
|
||||
*sk = unsolved;
|
||||
}
|
||||
Ok(rep)
|
||||
}
|
||||
|
||||
/// The sketch solves of `edit` and drags, counted in tests (see [`solve_with`]).
|
||||
pub(super) fn counted_solve(
|
||||
sk: &mut Sketch,
|
||||
hold: &Hold,
|
||||
reference: Option<&[Vec2]>,
|
||||
analysis: Analysis,
|
||||
budget: Option<&mut Budget>,
|
||||
) -> SolveReport {
|
||||
#[cfg(test)]
|
||||
solve_counts::record(analysis);
|
||||
solve_with(sk, hold, reference, analysis, budget)
|
||||
}
|
||||
|
||||
/// Inside an `edit` closure: solve the sketch for the document's parameter values from its
|
||||
/// stored solved state, as the edit's own solve does, for commands that work on the current
|
||||
/// geometry.
|
||||
pub(super) fn solve_current(sk: &mut Sketch, doc: &solvecraft_doc::Document) -> Result<SolveReport> {
|
||||
let stored = sk.driving_values();
|
||||
let (values, _) = doc.param_values();
|
||||
Ok(doc.solve_sketch(&values, sk, &stored)?)
|
||||
}
|
||||
|
||||
/// Solves run on this thread by [`counted_solve`] and the edit's own solve: (all of them, those
|
||||
/// with a rank analysis).
|
||||
#[cfg(test)]
|
||||
pub(super) mod solve_counts {
|
||||
use std::cell::Cell;
|
||||
|
||||
use solvecraft_sketch::Analysis;
|
||||
|
||||
thread_local! {
|
||||
static COUNTS: Cell<(usize, usize)> = const { Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
pub fn record(analysis: Analysis) {
|
||||
COUNTS.with(|c| {
|
||||
let (n, full) = c.get();
|
||||
c.set((n + 1, full + usize::from(analysis == Analysis::Full)));
|
||||
});
|
||||
}
|
||||
|
||||
/// The counts since the last call.
|
||||
pub fn take() -> (usize, usize) {
|
||||
COUNTS.with(|c| c.replace((0, 0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How close (mm) a new point must land to an existing one to be connected to it.
|
||||
@@ -1165,7 +1289,7 @@ pub(super) fn apply_in_order(sk: &mut Sketch, rep: &SolveReport, first: &[Ent],
|
||||
driven: false,
|
||||
text: None,
|
||||
}));
|
||||
let ok = solve_holding(&mut trial, &h).ok();
|
||||
let ok = solve_with(&mut trial, &h, None, Analysis::Full, None).ok();
|
||||
trial.constraints.retain(|c| c.id != TEMP);
|
||||
if ok && !collapsed(sk, &trial) {
|
||||
*sk = trial;
|
||||
@@ -1240,7 +1364,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
|
||||
{
|
||||
let rep = solve(&mut sk.clone());
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": {"solved": rep.ok(), "dof": rep.dof}}));
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
|
||||
}
|
||||
constrain(s, p, cmd, |sk| {
|
||||
if let Some((a, b)) = line_points {
|
||||
@@ -1486,7 +1610,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
((None, Some(c)), None) if is_line(c) => {
|
||||
let (a, b) = line_pts(sk, c).unwrap_or_default();
|
||||
match ty.as_str() {
|
||||
match auto_orientation(&ty, a, b, text_at) {
|
||||
"angle" => {
|
||||
let angle = (b - a).angle();
|
||||
(ConstraintKind::LineAngle { l: c, value: angle }, angle)
|
||||
@@ -1523,7 +1647,7 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
((Some(a), None), Some((Some(b), None))) => {
|
||||
let (pa, pb) = (sk.point(a).unwrap_or_default(), sk.point(b).unwrap_or_default());
|
||||
match ty.as_str() {
|
||||
match auto_orientation(&ty, pa, pb, text_at) {
|
||||
"horizontal" => (ConstraintKind::DistanceX { p: a, q: b, value: (pb.x - pa.x).abs() }, (pb.x - pa.x).abs()),
|
||||
"vertical" => (ConstraintKind::DistanceY { p: a, q: b, value: (pb.y - pa.y).abs() }, (pb.y - pa.y).abs()),
|
||||
_ => (ConstraintKind::Distance { p: a, q: b, value: pa.dist(pb) }, pa.dist(pb)),
|
||||
@@ -1643,6 +1767,28 @@ fn solved_measure(s: &Session, p: &Value, cmd: &str) -> Option<(f64, &'static st
|
||||
Some(measured(k.is_angle(), solvecraft_sketch::measure_dimension(sk, k)?))
|
||||
}
|
||||
|
||||
/// The type of a dimension between `a` and `b` (two points or a line's ends). `auto` takes it
|
||||
/// from where the text is placed: across from the two (between the perpendiculars through
|
||||
/// them) the distance between them; past that, above or below them the horizontal distance,
|
||||
/// beside them the vertical one (#257). With no text, or when the two already line up with
|
||||
/// an axis, `auto` stays the distance between them; other types are returned unchanged.
|
||||
fn auto_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option<Vec2>) -> &str {
|
||||
let Some(t) = text_at.filter(|_| ty == "auto") else { return ty };
|
||||
let d = b - a;
|
||||
let len = d.len();
|
||||
let along = (t - a).dot(d) / len;
|
||||
let between = |v: f64, p: f64, q: f64| p.min(q) <= v && v <= p.max(q);
|
||||
if d.x.abs() <= 1e-9 * len || d.y.abs() <= 1e-9 * len || !along.is_finite() || (0.0..=len).contains(&along) {
|
||||
ty
|
||||
} else if between(t.x, a.x, b.x) {
|
||||
"horizontal"
|
||||
} else if between(t.y, a.y, b.y) {
|
||||
"vertical"
|
||||
} else {
|
||||
ty
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the
|
||||
/// dimension so it follows the geometry.
|
||||
fn place_text(sk: &mut Sketch, id: &str, at: Option<Vec2>) {
|
||||
@@ -1823,109 +1969,82 @@ fn sketch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn move_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.move_point";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let (_, rep) = edit_solved(s, id, false, |sk, _| {
|
||||
let q = point_ref(sk, p.get("point"), cmd, "point")?;
|
||||
drag_entity(sk, Ent::Point(q), to, to);
|
||||
Ok(())
|
||||
Ok(((), drag_entity(sk, Ent::Point(q), to, to, false).1.solve_again()))
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
Ok(json!({"sketch": rep.as_ref().map(report_json)}))
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = p.get("from").and_then(vec2).unwrap_or(to);
|
||||
let (_, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
drag_entity(sk, e, from, to);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"sketch": info}))
|
||||
drag_frame(s, p, false).map(|(v, _)| v)
|
||||
}
|
||||
|
||||
/// Drag like Fusion: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can. Fixed and
|
||||
/// fully constrained geometry stays (the solve that follows pulls it back).
|
||||
fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2) {
|
||||
let d = to - from;
|
||||
let mut hold = Hold::default();
|
||||
match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return;
|
||||
}
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = to;
|
||||
}
|
||||
hold.points.push(q);
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return;
|
||||
}
|
||||
let Some(kind) = sk.curves.get(c).map(|c| c.kind.clone()) else { return };
|
||||
let locked = sk.locked_points();
|
||||
let set = |sk: &mut Sketch, q: usize, at: Vec2| {
|
||||
if !locked.get(q).copied().unwrap_or(true)
|
||||
&& let Some(pt) = sk.points.get_mut(q)
|
||||
{
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, .. } => {
|
||||
let r = sk.point(cc).map(|o| o.dist(to)).unwrap_or(0.0);
|
||||
if let Some(CurveKind::Circle { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
&& r > 1e-9
|
||||
{
|
||||
*slot = r;
|
||||
}
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let Some(o) = sk.point(cc) else { return };
|
||||
let r = o.dist(to);
|
||||
if r > 1e-9 {
|
||||
for q in [a, b] {
|
||||
if let Some(u) = sk.point(q).and_then(|pq| (pq - o).normalized()) {
|
||||
set(sk, q, o + u * r);
|
||||
}
|
||||
}
|
||||
}
|
||||
hold.points.extend([cc, a, b]);
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, .. } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (Some(o), Some(pm)) = (sk.point(cc), sk.point(m)) else { return };
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
if minor > 1e-9
|
||||
&& let Some(CurveKind::Ellipse { r: slot, .. }) = sk.curves.get_mut(c).map(|x| &mut x.kind)
|
||||
{
|
||||
*slot = minor;
|
||||
}
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
for q in k.point_ids().into_iter().collect::<std::collections::BTreeSet<_>>() {
|
||||
if let Some(at) = sk.point(q) {
|
||||
set(sk, q, at + d);
|
||||
}
|
||||
}
|
||||
hold.points.extend(k.point_ids());
|
||||
}
|
||||
}
|
||||
}
|
||||
fn drag_live(s: &mut Session, p: &Value) -> Result<(Value, Option<crate::LiveEdit>)> {
|
||||
drag_frame(s, p, true)
|
||||
}
|
||||
|
||||
/// `sketch.drag`; `live`: one frame of an interactive drag, returned with the sketch to show.
|
||||
fn drag_frame(s: &mut Session, p: &Value, live: bool) -> Result<(Value, Option<crate::LiveEdit>)> {
|
||||
let cmd = "sketch.drag";
|
||||
let to = req_vec2(cmd, p, "to")?;
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => to,
|
||||
Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`from` must be a point [x, y]"))?,
|
||||
};
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
// The sketch's report as last evaluated: a live frame keeps its degrees of freedom.
|
||||
let last = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| ss.report.clone());
|
||||
let (d, rep) = edit_solved(s, id, false, |sk, doc| {
|
||||
let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a sketch point or curve"))?;
|
||||
let e = ent(sk, r).ok_or_else(|| bad(cmd, format!("unknown sketch entity `{r}`")))?;
|
||||
// The held solves work on the dimensions' current values (a solved step applies them).
|
||||
doc.apply_dimension_values(&doc.param_values().0, sk)?;
|
||||
let (d, left) = drag_entity(sk, e, from, to, live);
|
||||
// A live frame's held solve is its only one; otherwise the sketch's own solve reports
|
||||
// its degrees of freedom.
|
||||
Ok((d, if live && last.is_some() { left } else { left.solve_again() }))
|
||||
})?;
|
||||
// Where the grabbed spot went: `from` moved as the dragged points did on average (with the
|
||||
// whole step when it followed; otherwise as the sketch's own solve left them).
|
||||
let sk = s.doc.sketch(id)?;
|
||||
let moves: Vec<Vec2> = d.moving.iter().filter_map(|&(q, was)| Some(sk.point(q)? - was)).collect();
|
||||
let at = from + moves.iter().fold(Vec2::ZERO, |a, m| a + *m) * (1.0 / moves.len().max(1) as f64);
|
||||
let rep = rep.or_else(|| last.map(|last| SolveReport { status: solvecraft_sketch::SolveStatus::Solved, failing: Vec::new(), ..last }));
|
||||
let out = json!({"sketch": rep.as_ref().map(report_json), "reached": d.reached, "at": [at.x, at.y]});
|
||||
Ok((out, rep.filter(|_| live).map(|report| crate::LiveEdit { sketch: id, report })))
|
||||
}
|
||||
|
||||
/// Ends a live drag: the snap constraint it let go on, if any, then one evaluation of the model.
|
||||
/// It reads the model only before the sketch it edits (never stale), so `execute` does not
|
||||
/// evaluate first.
|
||||
fn drag_end(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.drag_end";
|
||||
let id = target_sketch(s, p, cmd)?;
|
||||
let start = s.doc.clone();
|
||||
let mut constraint = Value::Null;
|
||||
if let Some(c) = p.get("constraint").filter(|c| !c.is_null()) {
|
||||
let name = c.get("command").and_then(Value::as_str).unwrap_or_default();
|
||||
let spec = super::find_command(name)
|
||||
.filter(|spec| spec.id.starts_with("sketch.constraint."))
|
||||
.ok_or_else(|| bad(cmd, "`constraint.command` must be a sketch.constraint.* command"))?;
|
||||
let params = c.get("params").cloned().unwrap_or(Value::Null);
|
||||
// A constraint that cannot be added (it would over-constrain the sketch) is not kept.
|
||||
constraint = spec.run_nested(s, ¶ms).unwrap_or_else(|e| {
|
||||
s.doc = start.clone();
|
||||
json!({"error": e.to_string()})
|
||||
});
|
||||
}
|
||||
let mut trial = sk.clone();
|
||||
if solve_holding(&mut trial, &hold).ok() {
|
||||
*sk = trial;
|
||||
// A new constraint changed the document: `execute` evaluates the model after this.
|
||||
if !std::sync::Arc::ptr_eq(&start, &s.doc) {
|
||||
return Ok(json!({"constraint": constraint, "sketch": constraint.get("sketch")}));
|
||||
}
|
||||
if s.model.is_stale() {
|
||||
s.refresh();
|
||||
}
|
||||
let report = s.model.result(id).and_then(|r| r.state.sketch(id)).map(|ss| report_json(&ss.report));
|
||||
Ok(json!({"constraint": constraint, "sketch": report}))
|
||||
}
|
||||
|
||||
/// The Sketch Palette options of a sketch, by their palette names (true = shown / on).
|
||||
@@ -2000,6 +2119,9 @@ mod rect_tests;
|
||||
#[path = "sketch_ux_tests.rs"]
|
||||
mod ux_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_drag_live_tests.rs"]
|
||||
mod drag_live_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_ids_tests.rs"]
|
||||
mod ids_tests;
|
||||
|
||||
@@ -249,9 +249,13 @@ fn auto(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
Some(r) => sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown point `{r}`")))?,
|
||||
None => 0,
|
||||
};
|
||||
// Values of existing dimensions must be applied before judging degrees of freedom.
|
||||
let (vals, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&vals, sk)?;
|
||||
// Values of existing dimensions must be applied (and reached from the stored solved
|
||||
// state) before judging degrees of freedom; a sketch they would turn over keeps its
|
||||
// stored geometry, as the edit's own solve then refuses them.
|
||||
let rep = super::sketch::solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|f| format!("the sketch constraints conflict ({f})"))));
|
||||
}
|
||||
Ok(run_auto(sk, doc, p, datum))
|
||||
})?;
|
||||
Ok(json!({"added": added, "dof": dof, "sketch": info}))
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Dragging sketch geometry without jumping to another solution.
|
||||
//!
|
||||
//! A constrained sketch usually has several solutions for one cursor position (a two-link arm's
|
||||
//! elbow up or down, a fillet inside or outside its corner). Each held solve of a drag is seeded
|
||||
//! on the positions from before it: the points the solver's construction places (from distances
|
||||
//! and alignments to placed points) keep the orientation they had there however far the step.
|
||||
//! The rest is left to the iteration, which from far away can land on any solution, so a long
|
||||
//! drag still walks from where the geometry is to where the cursor puts it in short steps, each
|
||||
//! solved from the previous step's result (continuation). Continuation is what keeps lengths the
|
||||
//! construction does not read (an Equal to another line, a rigid shape hanging off a held point);
|
||||
//! it also lets the arc sweep guard see every step, and lets a path that crosses where the
|
||||
//! geometry cannot reach stop short at the edge of that stretch and go on past it. It steps only
|
||||
//! when the move is long next to the dragged geometry, so a normal mouse frame is a single solve.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch};
|
||||
|
||||
use super::{Ent, Left, counted_solve};
|
||||
|
||||
/// Longest step, as a fraction of the smallest driving length or radius of the dragged group.
|
||||
/// Each step is seeded on its pre-step positions, so what the seed constructs keeps its branch
|
||||
/// whatever the step; the steps are for what it does not (an arc's sweep guard, paths across
|
||||
/// unreachable stretches). The drag tests first fail at 1 (an arc end wraps through its start):
|
||||
/// this keeps a factor of four. It holds where the seed placed what it could: once a held
|
||||
/// solve's decomposition runs out of budget (see [`Budget::exhausted`]), the rest of the drag
|
||||
/// takes `SHORT_STEP_FRACTION` steps, and continuation keeps the branch.
|
||||
const STEP_FRACTION: f64 = 0.25;
|
||||
/// The steps of a drag whose seed did not place everything it could (as before seeding steps).
|
||||
const SHORT_STEP_FRACTION: f64 = 0.125;
|
||||
/// Shortest step (mm), so a tiny feature cannot make a drag crawl.
|
||||
const MIN_STEP: f64 = 1e-3;
|
||||
/// Most steps one drag is split into.
|
||||
const MAX_STEPS: usize = 64;
|
||||
/// After a step fails, the way to it from the last solved state is halved until what is left is
|
||||
/// shorter than a step over this; at most `MAX_STEPS` such solves per drag.
|
||||
const HALVINGS: usize = 16;
|
||||
/// An arc whose sweep gets this close (radians) to 0 or a full turn has run into its start.
|
||||
const SWEEP_EPS: f64 = 1e-6;
|
||||
|
||||
/// What a drag moves, from where it is (`t = 0`) to where the cursor puts it (`t = 1`).
|
||||
enum Path {
|
||||
/// Points slide by `d` (a dragged point by its way to the cursor).
|
||||
Slide { pts: Vec<(usize, Vec2)>, d: Vec2 },
|
||||
/// A circle's radius or an ellipse's minor radius follows the cursor.
|
||||
Radius { c: usize, from: f64, to: f64 },
|
||||
/// An arc's ends move along their rays from the centre `o` to the cursor's radius.
|
||||
ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 },
|
||||
}
|
||||
|
||||
impl Path {
|
||||
fn length(&self) -> f64 {
|
||||
match self {
|
||||
Path::Slide { d, .. } => d.len(),
|
||||
Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The points the drag moves.
|
||||
fn points(&self) -> impl Iterator<Item = usize> + '_ {
|
||||
let pts = match self {
|
||||
Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(),
|
||||
Path::Radius { .. } => &[],
|
||||
};
|
||||
pts.iter().map(|(q, _)| *q)
|
||||
}
|
||||
|
||||
/// Put the dragged geometry where it is at `t` along the drag.
|
||||
fn apply(&self, sk: &mut Sketch, t: f64) {
|
||||
let mut set = |q: usize, at: Vec2| {
|
||||
if let Some(pt) = sk.points.get_mut(q) {
|
||||
pt.pos = at;
|
||||
}
|
||||
};
|
||||
match self {
|
||||
Path::Slide { pts, d } => {
|
||||
for &(q, at) in pts {
|
||||
set(q, at + *d * t);
|
||||
}
|
||||
}
|
||||
Path::ArcRadius { o, ends, from, to } => {
|
||||
let r = from + (to - from) * t;
|
||||
for &(q, u) in ends {
|
||||
set(q, *o + u * r);
|
||||
}
|
||||
}
|
||||
Path::Radius { c, from, to } => {
|
||||
if let Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) {
|
||||
*r = from + (to - from) * t;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line,
|
||||
/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes
|
||||
/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the
|
||||
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
|
||||
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
|
||||
/// it, and the next steps are tried from the last solved state, the last of them being the whole
|
||||
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
|
||||
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
|
||||
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
|
||||
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
|
||||
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
|
||||
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
|
||||
///
|
||||
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
|
||||
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
|
||||
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
|
||||
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
|
||||
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
|
||||
};
|
||||
#[cfg(test)]
|
||||
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
|
||||
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
|
||||
let mut frame = live.then(frame_budget);
|
||||
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
|
||||
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
|
||||
// constraints pin down keeps the orientation it had there. A failed or wrapping step is
|
||||
// undone (a failed solve leaves the step placed). Also returns whether the decomposition ran
|
||||
// out of budget.
|
||||
let mut solves = Solves::default();
|
||||
let mut step = |sk: &mut Sketch, last: &Geometry, t: f64, solves: &mut Solves| {
|
||||
path.apply(sk, t);
|
||||
let mut own = Budget::full();
|
||||
let budget = frame.as_mut().unwrap_or(&mut own);
|
||||
let spent = budget.spent();
|
||||
let ok = counted_solve(sk, &hold, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok();
|
||||
solves.count += 1;
|
||||
solves.work += budget.spent() - spent;
|
||||
solves.cut |= budget.exhausted();
|
||||
let outcome = if !ok {
|
||||
Step::Failed
|
||||
} else if last.sweep_wrapped(sk) {
|
||||
Step::Wrapped
|
||||
} else {
|
||||
return Step::Solved;
|
||||
};
|
||||
last.restore(sk);
|
||||
outcome
|
||||
};
|
||||
let mut last = Geometry::default();
|
||||
last.take(sk);
|
||||
// How far along the last solved state is; the first step since it that failed.
|
||||
let (mut good, mut failed) = (0.0, None);
|
||||
let mut spare = MAX_STEPS;
|
||||
// The steps: `k` of `dt` from `base`; once a seed ran out of budget, the rest of the way in
|
||||
// short steps.
|
||||
let (mut base, mut dt, mut k, mut t) = (0.0, 1.0 / n as f64, 0, 0.0);
|
||||
let mut shortened = false;
|
||||
'walk: while t < 1.0 {
|
||||
if solves.cut && !shortened {
|
||||
shortened = true;
|
||||
let rest = 1.0 - t;
|
||||
let m = steps(rest * short as f64, 1.0);
|
||||
(base, dt, k) = (t, rest / m as f64, 0);
|
||||
}
|
||||
k += 1;
|
||||
t = base + dt * k as f64;
|
||||
// The last step ends exactly at the cursor.
|
||||
if 1.0 - t < 0.5 * dt {
|
||||
t = 1.0;
|
||||
}
|
||||
match step(sk, &last, t, &mut solves) {
|
||||
Step::Solved => {
|
||||
(good, failed) = (t, None);
|
||||
if t < 1.0 {
|
||||
last.take(sk);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Step::Wrapped => {
|
||||
failed = None;
|
||||
break 'walk;
|
||||
}
|
||||
Step::Failed => {}
|
||||
}
|
||||
if failed.is_none() {
|
||||
// Halve the way to the failed step, so the state the next steps start from (and are
|
||||
// seeded on) is as close to it as the geometry follows. The shortest move is tried
|
||||
// first: geometry that cannot move at all (pinned) costs one solve, not every halving.
|
||||
let fine = dt / HALVINGS as f64;
|
||||
let mut miss = t;
|
||||
let mut first = true;
|
||||
while spare > 0 && miss - good > fine {
|
||||
spare -= 1;
|
||||
let mid = if std::mem::take(&mut first) { good + fine } else { 0.5 * (good + miss) };
|
||||
match step(sk, &last, mid, &mut solves) {
|
||||
Step::Solved => {
|
||||
good = mid;
|
||||
last.take(sk);
|
||||
}
|
||||
Step::Wrapped => break 'walk,
|
||||
Step::Failed => miss = mid,
|
||||
}
|
||||
}
|
||||
failed = Some(t);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
last_drag::set(solves);
|
||||
let left = match failed {
|
||||
// The failed step is left placed for the edit's solve, seeded on the last solved state.
|
||||
Some(t) => {
|
||||
path.apply(sk, t);
|
||||
Left::MovedFrom(last.pos)
|
||||
}
|
||||
None if good > 0.0 => Left::Solved,
|
||||
None => Left::Unsolved,
|
||||
};
|
||||
(Dragged { reached: good, moving }, left)
|
||||
}
|
||||
|
||||
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
|
||||
/// out of budget.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(super) struct Solves {
|
||||
pub count: usize,
|
||||
pub work: usize,
|
||||
pub cut: bool,
|
||||
}
|
||||
|
||||
/// One live frame's decomposition budget (smaller in tests that set one).
|
||||
fn frame_budget() -> Budget {
|
||||
#[cfg(test)]
|
||||
if let Some(work) = last_drag::frame_work() {
|
||||
return Budget::new(work);
|
||||
}
|
||||
Budget::frame()
|
||||
}
|
||||
|
||||
/// What the last drag on this thread did, and a frame budget for tests to set.
|
||||
#[cfg(test)]
|
||||
pub(super) mod last_drag {
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::Solves;
|
||||
|
||||
thread_local! {
|
||||
static LAST: Cell<Solves> = const { Cell::new(Solves { count: 0, work: 0, cut: false }) };
|
||||
static FRAME: Cell<Option<usize>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
pub fn set(s: Solves) {
|
||||
LAST.with(|c| c.set(s));
|
||||
}
|
||||
|
||||
/// The held solves of the last drag.
|
||||
pub fn get() -> Solves {
|
||||
LAST.with(Cell::get)
|
||||
}
|
||||
|
||||
pub fn frame_work() -> Option<usize> {
|
||||
FRAME.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores the frame budget when dropped, also when the test panics.
|
||||
struct Restore(Option<usize>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
FRAME.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with live frames given `work` units of decomposition budget.
|
||||
pub fn with_frame_work<R>(work: usize, f: impl FnOnce() -> R) -> R {
|
||||
let _restore = Restore(FRAME.with(|c| c.replace(Some(work))));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// How one held solve of a drag went.
|
||||
enum Step {
|
||||
Solved,
|
||||
Failed,
|
||||
/// Solved, but it swept an arc through its own start.
|
||||
Wrapped,
|
||||
}
|
||||
|
||||
/// How far a drag got.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Dragged {
|
||||
/// The part of the way from where it was grabbed to the cursor the geometry followed (0..1).
|
||||
pub reached: f64,
|
||||
/// The points the drag moves (not the fixed ones it holds), where they were before it.
|
||||
pub moving: Vec<(usize, Vec2)>,
|
||||
}
|
||||
|
||||
/// The geometry a step changes (point positions; curves, for radii and the arc ends a solve
|
||||
/// may swap), kept to undo a step. Its buffers are reused from step to step.
|
||||
#[derive(Default)]
|
||||
struct Geometry {
|
||||
pos: Vec<Vec2>,
|
||||
curves: Vec<(CurveKind, bool)>,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn take(&mut self, sk: &Sketch) {
|
||||
self.pos.clear();
|
||||
self.pos.extend(sk.points.iter().map(|p| p.pos));
|
||||
self.curves.clear();
|
||||
self.curves.extend(sk.curves.iter().map(|c| (c.kind.clone(), c.reversed)));
|
||||
}
|
||||
|
||||
fn restore(&self, sk: &mut Sketch) {
|
||||
for (p, at) in sk.points.iter_mut().zip(&self.pos) {
|
||||
p.pos = *at;
|
||||
}
|
||||
for (c, (kind, reversed)) in sk.curves.iter_mut().zip(&self.curves) {
|
||||
c.kind = kind.clone();
|
||||
c.reversed = *reversed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the step drive some arc's end through its start: the sweep jumped through 0 / a full
|
||||
/// turn, or ran from clear of them onto one? An arc already that close may still move.
|
||||
/// Measured on the arc's ends as they were before the step (a solve may swap them).
|
||||
fn sweep_wrapped(&self, sk: &Sketch) -> bool {
|
||||
let was = |q: usize| self.pos.get(q).copied();
|
||||
let now = |q: usize| sk.point(q);
|
||||
let clear = |s: f64| (SWEEP_EPS..=TAU - SWEEP_EPS).contains(&s);
|
||||
self.curves.iter().any(|(kind, _)| {
|
||||
let CurveKind::Arc { c, a, b } = *kind else { return false };
|
||||
let (Some(s0), Some(s1)) = (sweep(was(c), was(a), was(b)), sweep(now(c), now(a), now(b))) else { return false };
|
||||
(s1 - s0).abs() > PI || (clear(s0) && !clear(s1))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The held solves dragging `e` from `from` to `to` takes (0 when it cannot move): the plan
|
||||
/// `drag_entity` runs, for tests.
|
||||
#[cfg(test)]
|
||||
pub(super) fn drag_steps(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> usize {
|
||||
prepare(sk, e, from, to).map_or(0, |(_, _, n, _)| n)
|
||||
}
|
||||
|
||||
/// A drag's path, what its solves hold and its number of steps (and of short steps, see
|
||||
/// `SHORT_STEP_FRACTION`), or `None` if it cannot move.
|
||||
fn prepare(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold, usize, usize)> {
|
||||
if !(from.is_finite() && to.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (path, hold) = plan(sk, e, from, to)?;
|
||||
let n = step_count(sk, &hold, path.length(), STEP_FRACTION);
|
||||
let short = step_count(sk, &hold, path.length(), SHORT_STEP_FRACTION);
|
||||
Some((path, hold, n, short))
|
||||
}
|
||||
|
||||
/// A cap on the steps `drag_entity` takes on this thread, for tests that measure what fewer
|
||||
/// steps keep (the planned count, `drag_steps`, is not capped).
|
||||
#[cfg(test)]
|
||||
pub(super) mod step_cap {
|
||||
use std::cell::Cell;
|
||||
|
||||
thread_local! {
|
||||
static CAP: Cell<usize> = const { Cell::new(super::MAX_STEPS) };
|
||||
}
|
||||
|
||||
pub fn get() -> usize {
|
||||
CAP.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores the cap when dropped, also when the test panics.
|
||||
struct Restore(usize);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
CAP.with(|c| c.set(self.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with drags split into at most `cap` steps (at least one).
|
||||
pub fn with<R>(cap: usize, f: impl FnOnce() -> R) -> R {
|
||||
let _restore = Restore(CAP.with(|c| c.replace(cap.max(1))));
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
/// Steps of at most `step` covering `length`: at least one, at most `MAX_STEPS`.
|
||||
fn steps(length: f64, step: f64) -> usize {
|
||||
let n = (length / step).ceil();
|
||||
if n.is_finite() && n >= 1.0 { (n as usize).min(MAX_STEPS) } else { 1 }
|
||||
}
|
||||
|
||||
/// The dragged geometry's path and what the solves hold, or `None` if it cannot move.
|
||||
fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> {
|
||||
let mut hold = Hold::default();
|
||||
let path = match e {
|
||||
Ent::Point(q) => {
|
||||
if sk.point_locked(q) {
|
||||
return None;
|
||||
}
|
||||
hold.points.push(q);
|
||||
let at = sk.point(q)?;
|
||||
Path::Slide { pts: vec![(q, at)], d: to - at }
|
||||
}
|
||||
Ent::Curve(c) => {
|
||||
if sk.curve_locked(c) {
|
||||
return None;
|
||||
}
|
||||
let kind = sk.curves.get(c).map(|x| x.kind.clone())?;
|
||||
let locked = sk.locked_points();
|
||||
let free = |q: &usize| !locked.get(*q).copied().unwrap_or(true);
|
||||
// A cursor on the centre leaves the radius as it is.
|
||||
let radius_to = |o: Vec2, r: f64| Some(o.dist(to)).filter(|x| *x > 1e-9).unwrap_or(r);
|
||||
match kind {
|
||||
CurveKind::Circle { c: cc, r } => {
|
||||
let to_r = sk.point(cc).map_or(r, |o| radius_to(o, r));
|
||||
hold.points.push(cc);
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: to_r }
|
||||
}
|
||||
CurveKind::Arc { c: cc, a, b } => {
|
||||
let o = sk.point(cc)?;
|
||||
let r = sk.radius(c).unwrap_or(0.0);
|
||||
hold.points.extend([cc, a, b]);
|
||||
let ends = [a, b].into_iter().filter(free).filter_map(|q| Some((q, (sk.point(q)? - o).normalized()?))).collect();
|
||||
Path::ArcRadius { o, ends, from: r, to: radius_to(o, r) }
|
||||
}
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
// Grabbed on the outline: the minor radius follows (the major stays).
|
||||
let (o, pm) = (sk.point(cc)?, sk.point(m)?);
|
||||
let u = (pm - o).normalized().unwrap_or(Vec2::X);
|
||||
let minor = u.cross(to - o).abs();
|
||||
hold.points.extend([cc, m]);
|
||||
hold.radii.push(c);
|
||||
Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } }
|
||||
}
|
||||
k => {
|
||||
// Lines, splines and conics slide with the cursor.
|
||||
let mut ids = k.point_ids();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let pts = ids.iter().filter(|q| free(q)).filter_map(|&q| Some((q, sk.point(q)?))).collect();
|
||||
hold.points.extend(ids);
|
||||
Path::Slide { pts, d: to - from }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((path, hold))
|
||||
}
|
||||
|
||||
/// Steps for a move of `length`: enough that none is longer than `fraction` of the smallest
|
||||
/// size of the geometry the solve moves with what it holds (at least `MIN_STEP`), at most
|
||||
/// `MAX_STEPS`. A move short next to every size in the sketch (a normal mouse frame) is one
|
||||
/// step, found without grouping the sketch.
|
||||
fn step_count(sk: &Sketch, hold: &Hold, length: f64, fraction: f64) -> usize {
|
||||
let step = |size: f64| (size * fraction).max(MIN_STEP);
|
||||
if length <= step(smallest_size(sk, hold, None)) {
|
||||
return 1;
|
||||
}
|
||||
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
|
||||
steps(length, step(smallest_size(sk, hold, Some(Group { points: &points, radii: &radii }))))
|
||||
}
|
||||
|
||||
/// What a solve moves together: per point and per curve radius.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Group<'a> {
|
||||
points: &'a [bool],
|
||||
radii: &'a [bool],
|
||||
}
|
||||
|
||||
impl Group<'_> {
|
||||
/// Does an entity with these points and curves belong to the group?
|
||||
fn has(&self, points: &[usize], curves: &[usize]) -> bool {
|
||||
let any = |flags: &[bool], ids: &[usize]| ids.iter().any(|i| flags.get(*i).copied().unwrap_or(false));
|
||||
any(self.points, points) || any(self.radii, curves)
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest size that can make a constrained shape take another solution (of the geometry
|
||||
/// in `group`, or of the whole sketch), or infinity when there is none: every length the
|
||||
/// constraints hold. That is driving length dimensions (offsets along an axis or from a line
|
||||
/// too: the solver holds their size, either side), radii and the current lengths of lines held
|
||||
/// equal to another (a link's length can come from an Equal as well as a dimension). A line that
|
||||
/// only keeps a direction (a rectangle's side) holds no length: it may pass through zero and turn
|
||||
/// over, so it does not shrink the steps. Nor does a distance between two held points (the
|
||||
/// dragged line's own length): held geometry moves as one. A circle's radius counts when the
|
||||
/// solve moves the radius itself (not merely its centre).
|
||||
fn smallest_size(sk: &Sketch, hold: &Hold, group: Option<Group>) -> f64 {
|
||||
// Point lists are built only to test a size smaller than the smallest so far against the
|
||||
// group; without a group nothing is built.
|
||||
let in_group = |points: &dyn Fn() -> Vec<usize>, curves: &[usize]| group.is_none_or(|g| g.has(&points(), curves));
|
||||
// A radius is the circle's own variable, or (an arc's) the distance between its points.
|
||||
let radius_in_group = |c: usize| match sk.curves.get(c).map(|x| &x.kind) {
|
||||
Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) => in_group(&Vec::new, &[c]),
|
||||
Some(k) => in_group(&|| k.point_ids(), &[]),
|
||||
None => false,
|
||||
};
|
||||
let line_length = |l: usize| super::line_pts(sk, l).map(|(a, b)| a.dist(b));
|
||||
let points = |k: &ConstraintKind| sk.constraint_points(k);
|
||||
let mut min = f64::INFINITY;
|
||||
let mut take = |size: f64, counts: &dyn Fn() -> bool| {
|
||||
let size = size.abs();
|
||||
if size.is_finite() && size > 1e-9 && size < min && counts() {
|
||||
min = size;
|
||||
}
|
||||
};
|
||||
for k in sk.constraints.iter().filter(|k| !k.driven).map(|k| &k.kind) {
|
||||
match *k {
|
||||
ConstraintKind::Equal { a, b } => {
|
||||
if let Some(v) = line_length(a).into_iter().chain(line_length(b)).reduce(f64::min) {
|
||||
take(v, &|| in_group(&|| points(k), &[]));
|
||||
}
|
||||
}
|
||||
ConstraintKind::Radius { c, value } => take(value, &|| radius_in_group(c)),
|
||||
ConstraintKind::Diameter { c, value } => take(value / 2.0, &|| radius_in_group(c)),
|
||||
_ => {
|
||||
if let Some(v) = driving_length(k) {
|
||||
// Held points move as one: a length between them never changes.
|
||||
take(v, &|| {
|
||||
let pts = points(k);
|
||||
!pts.iter().all(|p| hold.points.contains(p)) && in_group(&|| pts.clone(), &k.indices().1)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in 0..sk.curves.len() {
|
||||
if let Some(r) = sk.radius(c) {
|
||||
take(r, &|| radius_in_group(c));
|
||||
}
|
||||
}
|
||||
min
|
||||
}
|
||||
|
||||
/// The length a driving dimension sets (a linear diameter's radius), if it sets one.
|
||||
fn driving_length(k: &ConstraintKind) -> Option<f64> {
|
||||
if k.is_angle() {
|
||||
return None;
|
||||
}
|
||||
let v = k.value()?;
|
||||
Some(if matches!(k, ConstraintKind::LinearDiameter { .. }) { v / 2.0 } else { v })
|
||||
}
|
||||
|
||||
/// Counter-clockwise sweep (radians, `[0, 2π)`) from `a` to `b` about `c`.
|
||||
fn sweep(c: Option<Vec2>, a: Option<Vec2>, b: Option<Vec2>) -> Option<f64> {
|
||||
let (o, pa, pb) = (c?, a?, b?);
|
||||
Some(((pb - o).angle() - (pa - o).angle()).rem_euclid(TAU))
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Live drags (`sketch.drag` with `live: true`): each frame solves the sketch once, without a
|
||||
//! rank analysis, and evaluates nothing after it; the gesture's end evaluates the model once.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use super::solve_counts;
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimensioned 20 x 15 rectangle from (5, 5) extruded 5 mm, its sketch being edited again;
|
||||
/// returns the session and the extrude's id.
|
||||
fn extruded() -> (Session, u64) {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 15}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
let extrude = s.doc.features.last().map(|f| f.id).expect("the extrude");
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
(s, extrude)
|
||||
}
|
||||
|
||||
/// Counters from here on.
|
||||
fn reset(s: &mut Session) {
|
||||
s.evaluations = 0;
|
||||
solve_counts::take();
|
||||
}
|
||||
|
||||
/// Frames moving the rectangle's corner (5, 5) (and so the whole rectangle) left by 1 mm each.
|
||||
fn frames(s: &mut Session, n: usize, live: bool) -> Vec<Value> {
|
||||
(1..=n)
|
||||
.map(|k| {
|
||||
let (from, to) = ([6.0 - k as f64, 5.0], [5.0 - k as f64, 5.0]);
|
||||
run(s, "sketch.drag", json!({"entity": "l1.start", "from": from, "to": to, "live": live}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn body_contains(s: &Session, p: Vec3) -> bool {
|
||||
s.model.state().bodies.first().expect("a body").body.contains(p)
|
||||
}
|
||||
|
||||
fn pt(s: &Session, id: &str) -> solvecraft_geom::Vec2 {
|
||||
let sk = s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).expect("sketch mode");
|
||||
sk.resolve_point(id).and_then(|i| sk.point(i)).expect("the point")
|
||||
}
|
||||
|
||||
fn corner(s: &Session) -> solvecraft_geom::Vec2 {
|
||||
let id = s.active_sketch.expect("sketch mode");
|
||||
let st = s.world_state();
|
||||
let sk = &st.sketch(id).expect("the shown sketch").sketch;
|
||||
sk.resolve_point("l1.start").and_then(|i| sk.point(i)).expect("the corner")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_solves_once_and_leaves_the_features_after_the_sketch_stale() {
|
||||
let (mut s, extrude) = extruded();
|
||||
let body = s.model.state().bodies.first().expect("a body").body.clone();
|
||||
reset(&mut s);
|
||||
let r = frames(&mut s, 1, true).remove(0);
|
||||
assert_eq!(s.evaluations, 0, "a live frame evaluates no feature");
|
||||
assert_eq!(solve_counts::take(), (1, 0), "one held solve, no rank analysis");
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true));
|
||||
assert_eq!(r["sketch"]["dof"], json!(2), "the degrees of freedom from before the drag");
|
||||
// The sketch shows its new place; the extrude keeps its last result.
|
||||
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(4.0, 5.0)) < 1e-9);
|
||||
let result = s.model.result(extrude).expect("the extrude's result");
|
||||
assert!(result.stale);
|
||||
assert!(s.model.state().bodies.first().expect("a body").body.same(&body), "the body is not rebuilt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_drag_end_evaluates_once_to_the_scripted_drags_result() {
|
||||
let (mut live, extrude) = extruded();
|
||||
let (mut scripted, _) = extruded();
|
||||
frames(&mut live, 3, true);
|
||||
reset(&mut live);
|
||||
let end = run(&mut live, "sketch.drag_end", json!({}));
|
||||
assert_eq!(live.evaluations, 1, "one full evaluation at the end");
|
||||
assert_eq!(solve_counts::take(), (0, 0));
|
||||
assert_eq!(end["sketch"], json!({"solved": true, "dof": 2}));
|
||||
assert!(!live.model.is_stale());
|
||||
frames(&mut scripted, 3, false);
|
||||
assert_eq!(*live.doc, *scripted.doc, "the same design as dragging without `live`");
|
||||
let (a, b) = (live.model.state(), scripted.model.state());
|
||||
let (sa, sb) = (&a.sketches[0], &b.sketches[0]);
|
||||
assert_eq!(sa.sketch, sb.sketch);
|
||||
assert_eq!((sa.report.dof, &sa.report.point_determined), (sb.report.dof, &sb.report.point_determined));
|
||||
assert!(body_contains(&live, Vec3::new(2.5, 6.0, 2.5)) && !body_contains(&live, Vec3::new(22.5, 6.0, 2.5)), "the body followed");
|
||||
assert!(live.model.result(extrude).is_some_and(|r| !r.stale && r.error.is_none()));
|
||||
// One undo step for the gesture is the UI's business; each frame is still undoable.
|
||||
run(&mut live, "edit.undo", json!({}));
|
||||
assert!(corner(&live).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_live_drag_is_evaluated_before_the_next_command() {
|
||||
let (mut s, extrude) = extruded();
|
||||
frames(&mut s, 2, true);
|
||||
assert!(s.model.is_stale());
|
||||
reset(&mut s);
|
||||
// A command that changes nothing still sees (and leaves) a fully evaluated model.
|
||||
run(&mut s, "sketch.solve", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
|
||||
assert!(body_contains(&s, Vec3::new(3.5, 6.0, 2.5)));
|
||||
// Leaving the sketch does too.
|
||||
frames(&mut s, 1, true);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(!s.model.is_stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scripted_drag_still_evaluates_the_whole_model() {
|
||||
let (mut s, extrude) = extruded();
|
||||
reset(&mut s);
|
||||
let r = frames(&mut s, 1, false).remove(0);
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert_eq!(solve_counts::take(), (2, 1), "the held solve, then the sketch's own solve with its analysis");
|
||||
assert_eq!(r["sketch"], json!({"solved": true, "dof": 2}));
|
||||
assert!(!s.model.is_stale());
|
||||
assert!(s.model.result(extrude).is_some_and(|r| !r.stale));
|
||||
assert!(body_contains(&s, Vec3::new(4.5, 6.0, 2.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_reports_how_far_the_geometry_followed() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 30], [40, 30]]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
for live in [false, true] {
|
||||
// A line of fixed length with a fixed end cannot slide: the sketch's own solve only
|
||||
// turns it a little toward the cursor, and the grab follows its free end.
|
||||
let before = pt(&s, "l1.end");
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l1", "from": [25, 10], "to": [25, 20], "live": live}));
|
||||
assert_eq!(r["reached"], json!(0.0), "{r}");
|
||||
let moved = pt(&s, "l1.end") - before;
|
||||
let at = crate::params::vec2(&r["at"]).expect("`at`");
|
||||
assert!(at.dist(solvecraft_geom::Vec2::new(25.0, 10.0) + moved) < 1e-9 && at.y < 20.0, "{r}");
|
||||
let to = 40 + 10 * usize::from(live);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2", "from": [25, to - 10], "to": [25, to]}));
|
||||
assert_eq!((&r["reached"], &r["at"]), (&json!(1.0), &json!([25.0, to as f64])), "{r}");
|
||||
}
|
||||
// A point that cannot move at all.
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [0, 0], "live": true}));
|
||||
assert_eq!(r["reached"], json!(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_drag_of_a_lone_sketch_shows_it_without_evaluating() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [25, 20]}));
|
||||
reset(&mut s);
|
||||
frames(&mut s, 2, true);
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert_eq!(solve_counts::take(), (2, 0));
|
||||
// The free rectangle's corner moves alone; its profile follows.
|
||||
assert!(corner(&s).dist(solvecraft_geom::Vec2::new(3.0, 5.0)) < 1e-9);
|
||||
let st = s.model.state();
|
||||
let profile = st.sketches[0].profiles.first().expect("a profile");
|
||||
assert!((profile.area - 22.0 * 15.0).abs() < 1e-9, "{}", profile.area);
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
}
|
||||
|
||||
/// Frames of a UI drag of `entity` grabbed at `grab`, the cursor moving by `moves` in turn: each
|
||||
/// frame's `from` is the previous frame's `at`, as the viewport does.
|
||||
fn ui_frames(s: &mut Session, entity: &str, grab: [f64; 2], moves: &[[f64; 2]]) {
|
||||
let (mut from, mut cursor) = (grab, grab);
|
||||
for m in moves {
|
||||
cursor = [cursor[0] + m[0], cursor[1] + m[1]];
|
||||
let r = run(s, "sketch.drag", json!({"entity": entity, "from": from, "to": cursor, "live": true}));
|
||||
let at = crate::params::vec2(&r["at"]).expect("`at`");
|
||||
from = [at.x, at.y];
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grab_follows_the_points_that_move() {
|
||||
// A line with a fixed end, dragged by its middle: only the free end moves, and it keeps the
|
||||
// cursor's offset frame after frame.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [30, 10]]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
ui_frames(&mut s, "l1", [20.0, 10.0], &[[0.0, 1.0]; 6]);
|
||||
assert!(pt(&s, "l1.end").dist(solvecraft_geom::Vec2::new(30.0, 16.0)) < 1e-9, "{:?}", pt(&s, "l1.end"));
|
||||
assert!(pt(&s, "l1.start").dist(solvecraft_geom::Vec2::new(10.0, 10.0)) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_query_during_a_live_drag_does_not_evaluate_the_model() {
|
||||
let (mut s, _) = extruded();
|
||||
frames(&mut s, 1, true);
|
||||
reset(&mut s);
|
||||
for (id, p) in
|
||||
[("parameters.complete", json!({"prefix": "d"})), ("parameters.list", json!({})), ("expr.evaluate", json!({"expression": "d1 * 2"}))]
|
||||
{
|
||||
run(&mut s, id, p);
|
||||
}
|
||||
assert_eq!(s.evaluations, 0);
|
||||
assert!(s.model.is_stale(), "the drag goes on");
|
||||
frames(&mut s, 1, true);
|
||||
assert_eq!(s.evaluations, 0);
|
||||
}
|
||||
|
||||
fn sketch_of(s: &Session) -> solvecraft_sketch::Sketch {
|
||||
s.active_sketch.and_then(|i| s.doc.sketch(i).ok()).cloned().expect("sketch mode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_solves_the_dimensions_current_values() {
|
||||
let (mut s, _) = extruded();
|
||||
// The bottom side's parameter now says 25 while the sketch still holds 20 mm.
|
||||
let sk = sketch_of(&s);
|
||||
let l1 = sk.curve_index("l1").expect("l1");
|
||||
let param = sk
|
||||
.constraints
|
||||
.iter()
|
||||
.find(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Length { l, .. } if l == l1))
|
||||
.and_then(|c| c.param.clone())
|
||||
.expect("l1's dimension");
|
||||
if let Some(p) = s.doc_mut().params.iter_mut().find(|p| p.name == param) {
|
||||
p.expr = "25".into();
|
||||
}
|
||||
let r = frames(&mut s, 1, true).remove(0);
|
||||
assert_eq!(r["sketch"]["solved"], json!(true));
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!((a.dist(b) - 25.0).abs() < 1e-6, "l1 is {} mm", a.dist(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_frame_swaps_only_the_dragged_sketch() {
|
||||
let (mut s, _) = extruded();
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 50], "radius": 4}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"}));
|
||||
let id = s.active_sketch.expect("sketch mode");
|
||||
let other = |s: &Session| s.model.state().sketches.iter().find(|x| x.feature != id).cloned().expect("the other sketch");
|
||||
let before = other(&s);
|
||||
frames(&mut s, 1, true);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &other(&s)), "the other sketch is shared, not copied");
|
||||
// Later frames change the state they own in place.
|
||||
let state = std::sync::Arc::as_ptr(&s.model.state());
|
||||
frames(&mut s, 1, true);
|
||||
assert_eq!(state, std::sync::Arc::as_ptr(&s.model.state()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letting_go_on_a_snap_or_undoing_evaluates_once() {
|
||||
let (mut s, _) = extruded();
|
||||
run(&mut s, "sketch.point", json!({"point": [-10, 5], "connect": false}));
|
||||
let point = sketch_of(&s).points.last().map(|p| p.id.clone()).expect("the point");
|
||||
frames(&mut s, 3, true);
|
||||
reset(&mut s);
|
||||
let snap = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": point}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": snap }));
|
||||
assert_eq!(s.evaluations, 1, "{r}");
|
||||
assert!(r["constraint"].get("error").is_none(), "{r}");
|
||||
assert!(!s.model.is_stale());
|
||||
// Undo after an abandoned drag.
|
||||
frames(&mut s, 1, true);
|
||||
reset(&mut s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.evaluations, 1);
|
||||
assert!(!s.model.is_stale());
|
||||
// A snap that over-constrains the sketch is not kept; the drag still ends.
|
||||
frames(&mut s, 1, true);
|
||||
let bad = json!({"command": "sketch.constraint.coincident", "params": {"a": "l1.start", "b": "l1.end"}});
|
||||
let r = run(&mut s, "sketch.drag_end", json!({ "constraint": bad }));
|
||||
assert!(r["constraint"]["error"].is_string(), "{r}");
|
||||
assert!(!s.model.is_stale());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use solvecraft_doc::ProfileSel;
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{ConstraintKind, Curve, CurveKind, Shape, Sketch, intersections};
|
||||
|
||||
use super::sketch::{add_c, edit, ids_of};
|
||||
use super::sketch::{add_c, edit, ids_of, solve_current};
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::{Result, Session};
|
||||
@@ -378,10 +378,9 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.extend";
|
||||
let at = req_vec2(cmd, p, "at")?;
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve the existing sketch constraints before extending"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "solve the existing sketch constraints before extending".into())));
|
||||
}
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
@@ -453,7 +452,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pin(sk, end, oi)?;
|
||||
let before = sk.points.iter().map(|p| p.pos).collect::<Vec<_>>();
|
||||
let hold = solvecraft_sketch::Hold { points: (0..sk.points.len()).filter(|i| *i != end).collect(), radii: (0..sk.curves.len()).collect() };
|
||||
if !solvecraft_sketch::solve_holding(sk, &hold).ok()
|
||||
if !solvecraft_sketch::solve_with(sk, &hold, None, solvecraft_sketch::Analysis::Full, None).ok()
|
||||
|| sk.point(end).is_none_or(|point| point.dist(q) > TOL)
|
||||
|| sk.points.iter().enumerate().any(|(i, point)| i != end && point.pos.dist(before[i]) > TOL)
|
||||
{
|
||||
@@ -928,11 +927,10 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let side = p.get("side").and_then(vec2);
|
||||
let use_chain = bool_(p, "chain").unwrap_or(true);
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "solve existing constraints before offsetting"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "solve existing constraints before offsetting".into())));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
let sel: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
|
||||
@@ -1137,14 +1135,9 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A parameter edit may have changed the evaluated source without
|
||||
// rewriting the stored fit points. Break must freeze the displayed,
|
||||
// current dimensioned geometry, rather than its stale stored predecessor.
|
||||
let (values, _) = doc.param_values();
|
||||
doc.apply_dimension_values(&values, sk)?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting source dimensions"));
|
||||
}
|
||||
sk.refresh_offsets()?;
|
||||
if !solvecraft_sketch::solve(sk).ok() {
|
||||
return Err(bad(cmd, "cannot break an offset with conflicting derived dimensions"));
|
||||
let rep = solve_current(sk, doc)?;
|
||||
if !rep.keeps_orientation() {
|
||||
return Err(bad(cmd, rep.failure(|_| "cannot break an offset with conflicting dimensions".into())));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
if let Some(association) = sk
|
||||
|
||||
@@ -499,3 +499,30 @@ fn project_and_include_from_other_bodies() {
|
||||
assert!(row["error"].is_null(), "{row}");
|
||||
assert!(row["warning"].as_str().is_some_and(|w| w.contains("lost")), "{row}");
|
||||
}
|
||||
|
||||
/// A cylinder's wall and the torus of a rounded rim are drawn as one face but stored in halves
|
||||
/// joined by hidden seams; projecting one projects the whole face, not an open half.
|
||||
#[test]
|
||||
fn faces_split_by_hidden_seams_project_whole() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XZ"}));
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"face":[20,0,5]}]}));
|
||||
let wall = run(&mut s, "sketch.inspect", json!({}));
|
||||
let heights: Vec<f64> =
|
||||
wall["curves"].as_array().unwrap().iter().flat_map(|c| [c["start_at"][1].as_f64().unwrap(), c["end_at"][1].as_f64().unwrap()]).collect();
|
||||
assert!(heights.iter().any(|y| y.abs() < 1e-6) && heights.iter().any(|y| (y.abs() - 10.).abs() < 1e-6), "{wall}");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.fillet", json!({"edges":[[20,0,10]],"radius":3}));
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
let t = 0.5f64.sqrt() * 3.;
|
||||
run(&mut s, "sketch.project", json!({"refs":[{"face":[17. + t, 0, 7. + t]}]}));
|
||||
let round = run(&mut s, "sketch.inspect", json!({}));
|
||||
let mut radii: Vec<f64> = round["curves"].as_array().unwrap().iter().map(|c| c["radius"].as_f64().unwrap()).collect();
|
||||
radii.sort_by(f64::total_cmp);
|
||||
assert_eq!(radii.len(), 4, "{round}");
|
||||
assert!(radii[..2].iter().all(|r| (r - 17.).abs() < 1e-6) && radii[2..].iter().all(|r| (r - 20.).abs() < 1e-6), "{round}");
|
||||
}
|
||||
|
||||
@@ -552,6 +552,584 @@ fn constrained_geometry_resists_drags() {
|
||||
assert!((a.y - b.y).abs() < 1e-6 && (a.dist(b) - 30.0).abs() < 1e-6, "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// How the arm's link lengths are set.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Links {
|
||||
/// Length dimensions on both links.
|
||||
Dimensioned,
|
||||
/// Equal to fixed reference lines (no length dimension anywhere).
|
||||
EqualToFixed,
|
||||
}
|
||||
|
||||
/// Two-link arm: shoulder `l1.start` fixed, |l1| = 30 and |l2| = 20, the hand `l2.end` dragged
|
||||
/// in one `sketch.drag` call along a straight path that stays clear of the singular poses. The
|
||||
/// elbow has two solutions for every hand position; a drag must keep the one it started on.
|
||||
/// 1000 deterministic cases (fixed-seed LCG). Returns (elbow flips, drags that stopped short).
|
||||
fn arm_drags(links: Links) -> (usize, usize) {
|
||||
use std::f64::consts::TAU;
|
||||
let (a, b) = (30.0, 20.0);
|
||||
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
|
||||
let mut s = new_sketch();
|
||||
// Away from the sketch origin so nothing connects to it.
|
||||
let o = v(200.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
match links {
|
||||
Links::Dimensioned => {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
|
||||
}
|
||||
Links::EqualToFixed => {
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 100.0], [o.x + a, o.y - 100.0]], "ids": ["r1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y - 120.0], [o.x + b, o.y - 120.0]], "ids": ["r2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["r1", "r2"]}));
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "r1", "b": "l1"}));
|
||||
run(&mut s, "sketch.constraint.equal", json!({"a": "r2", "b": "l2"}));
|
||||
}
|
||||
}
|
||||
let id = s.active_sketch.expect("sketch");
|
||||
let sk0 = sketch(&s);
|
||||
assert_eq!(sk0.constraints.iter().any(|c| c.kind.is_dimension()), matches!(links, Links::Dimensioned));
|
||||
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
|
||||
let (p1, p2, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.end"));
|
||||
let p2b = ix("l2.start");
|
||||
let side = |sk: &Sketch| {
|
||||
let (x1, x2, x3) = (sk.points[p1].pos, sk.points[p2].pos, sk.points[p3].pos);
|
||||
(x3 - x1).cross(x2 - x1).signum()
|
||||
};
|
||||
let mut seed = 11u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut kept, mut flips, mut short) = (0, 0, 0);
|
||||
while kept < 1000 {
|
||||
let th1 = rnd() * TAU;
|
||||
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let v2 = o + v(a * th1.cos(), a * th1.sin());
|
||||
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
|
||||
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
|
||||
let to = o + v(tr * tt.cos(), tr * tt.sin());
|
||||
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
|
||||
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
{
|
||||
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
|
||||
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3)] {
|
||||
sk.points[p].pos = at;
|
||||
}
|
||||
}
|
||||
let before = side(&sketch(&s));
|
||||
drag(&mut s, "l2.end", [v3.x, v3.y], [to.x, to.y]);
|
||||
let sk = sketch(&s);
|
||||
if side(&sk) != before {
|
||||
flips += 1;
|
||||
}
|
||||
if sk.points[p3].pos.dist(to) > 1e-6 {
|
||||
short += 1;
|
||||
}
|
||||
}
|
||||
(flips, short)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dragged_two_link_arm_keeps_its_elbow_side() {
|
||||
for links in [Links::Dimensioned, Links::EqualToFixed] {
|
||||
let (flips, short) = arm_drags(links);
|
||||
eprintln!("two-link arm ({links:?}), one-call drags: {flips}/1000 elbow flips, {short} stopped short");
|
||||
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 drags");
|
||||
assert_eq!(short, 0, "{links:?}: {short}/1000 drags stopped short of the cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each step of a drag is seeded on the positions from before it, so even a drag made in one
|
||||
/// step (one held solve from the grab to the cursor) keeps the elbow of an arm whose links are
|
||||
/// dimensioned or held equal to fixed lines (the seed reads both).
|
||||
#[test]
|
||||
fn a_single_step_arm_drag_keeps_its_elbow_side() {
|
||||
for links in [Links::Dimensioned, Links::EqualToFixed] {
|
||||
let (flips, short) = super::drag::step_cap::with(1, || arm_drags(links));
|
||||
eprintln!("two-link arm ({links:?}), one-step drags: {flips}/1000 elbow flips, {short} stopped short");
|
||||
assert_eq!(flips, 0, "{links:?}: elbow flipped in {flips}/1000 one-step drags");
|
||||
assert_eq!(short, 0, "{links:?}: {short}/1000 one-step drags stopped short of the cursor");
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-link arm (shoulder fixed, |l1| = 30, |l2| = 20) with a thin triangle (sides 3.9, 2, 2)
|
||||
/// hanging off the hand: pinned there at one point, free to turn about it. The hand is dragged
|
||||
/// in one `sketch.drag` call along a straight path clear of the arm's singular poses, `n`
|
||||
/// deterministic cases with the triangle at random turns and on either side. Returns the drags
|
||||
/// that turned the triangle over.
|
||||
fn hanging_triangle_flips(n: usize) -> usize {
|
||||
use std::f64::consts::TAU;
|
||||
let (a, b) = (30.0, 20.0);
|
||||
let (lo, hi) = ((a - b) + 3.0, a + b - 3.0);
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + a, o.y], [o.x + a, o.y + b]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": a}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": b}));
|
||||
let hand = pt(&s, "l2.end");
|
||||
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
|
||||
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
|
||||
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
|
||||
}
|
||||
let id = s.active_sketch.expect("sketch");
|
||||
let sk0 = sketch(&s);
|
||||
let ix = |r: &str| sk0.resolve_point(r).unwrap_or_else(|| panic!("no point {r}"));
|
||||
let (p1, p2, p2b, p3) = (ix("l1.start"), ix("l1.end"), ix("l2.start"), ix("l2.end"));
|
||||
let (t1a, t1b, t2a, t2b, t3a, t3b) = (ix("t1.start"), ix("t1.end"), ix("t2.start"), ix("t2.end"), ix("t3.start"), ix("t3.end"));
|
||||
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
|
||||
let mut seed = 23u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut kept, mut flips) = (0, 0);
|
||||
while kept < n {
|
||||
let th1 = rnd() * TAU;
|
||||
let bend = (0.4 + rnd() * 2.2) * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let v2 = o + v(a * th1.cos(), a * th1.sin());
|
||||
let v3 = v2 + v(b * (th1 + bend).cos(), b * (th1 + bend).sin());
|
||||
let (tr, tt) = (lo + rnd() * (hi - lo), rnd() * TAU);
|
||||
let to = o + v(tr * tt.cos(), tr * tt.sin());
|
||||
let clear = |p: solvecraft_geom::Vec2| (lo..=hi).contains(&p.dist(o));
|
||||
if !(0..=100).all(|i| clear(v3 + (to - v3) * (i as f64 / 100.0))) {
|
||||
continue;
|
||||
}
|
||||
kept += 1;
|
||||
let turn = rnd() * TAU;
|
||||
let u = solvecraft_geom::Vec2::from_angle(turn);
|
||||
let normal = u.perp() * if rnd() < 0.5 { 1.0 } else { -1.0 };
|
||||
let (tq, tr) = (v3 + u * 3.9, v3 + u * 1.95 + normal * (4.0f64 - 1.95 * 1.95).sqrt());
|
||||
{
|
||||
let sk = s.doc_mut().sketch_mut(id).expect("sketch");
|
||||
for (p, at) in [(p1, o), (p2, v2), (p2b, v2), (p3, v3), (t1a, v3), (t1b, tq), (t2a, tq), (t2b, tr), (t3a, tr), (t3b, v3)] {
|
||||
sk.points[p].pos = at;
|
||||
}
|
||||
}
|
||||
let before = side(&s);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [v3.x, v3.y], "to": [to.x, to.y]}));
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?} vs {to:?}: {r}", pt(&s, "l2.end"));
|
||||
if side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
flips
|
||||
}
|
||||
|
||||
/// A rigid cluster pinned at a single point is placed by its reference pose (turned about the
|
||||
/// pin, never reflected): a thin triangle hanging off a dragged arm's hand never turns over,
|
||||
/// however few steps the drag takes.
|
||||
#[test]
|
||||
fn a_triangle_hanging_off_a_dragged_point_keeps_its_side() {
|
||||
let flips: Vec<(usize, usize)> =
|
||||
[1, 2, 4, usize::MAX].into_iter().map(|cap| (cap, super::drag::step_cap::with(cap, || hanging_triangle_flips(100)))).collect();
|
||||
eprintln!("thin triangle on a dragged hand, (step cap, turned over /100): {flips:?}");
|
||||
assert!(flips.iter().all(|(_, f)| *f == 0), "{flips:?}");
|
||||
}
|
||||
|
||||
/// Two-link arm with both ends fixed: the elbow is pinned to one of two places. Its drags (one
|
||||
/// step each) to `n` cursors across the shoulder-hand line all fail their held solve and leave
|
||||
/// the elbow placed there; returns how many of the sketch's own solves that follow put it on
|
||||
/// the other side. `move_point`: moved by `sketch.move_point` rather than `sketch.drag`.
|
||||
fn pinned_elbow_flips(n: usize, move_point: bool) -> usize {
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y + 10.0], [o.x + 40.0, o.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start", "l2.end"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
let start = s.doc.clone();
|
||||
let side = |s: &Session| (pt(s, "l2.end") - pt(s, "l1.start")).cross(pt(s, "l1.end") - pt(s, "l1.start")).signum();
|
||||
let before = side(&s);
|
||||
let mut flips = 0;
|
||||
for i in 0..n {
|
||||
s.doc = start.clone();
|
||||
let elbow = pt(&s, "l1.end");
|
||||
// Across the line, spread along it.
|
||||
let to = o + v(-10.0 + 60.0 * i as f64 / n as f64, -2.0 - 20.0 * ((i * 7) % n) as f64 / n as f64);
|
||||
let r = if move_point {
|
||||
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [to.x, to.y]}))
|
||||
} else {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "l1.end", "from": [elbow.x, elbow.y], "to": [to.x, to.y]}))
|
||||
};
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
if side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
flips
|
||||
}
|
||||
|
||||
/// A drag whose held solve fails leaves the step placed for the sketch's own solve, which keeps
|
||||
/// the orientation of the last solved state: an elbow pinned by both ends of its arm, dragged
|
||||
/// across the shoulder-hand line in one step, stays on its side.
|
||||
#[test]
|
||||
fn a_failed_drag_step_keeps_the_last_solved_orientation() {
|
||||
for move_point in [false, true] {
|
||||
let flips = super::drag::step_cap::with(1, || pinned_elbow_flips(50, move_point));
|
||||
eprintln!("pinned elbow, one-step moves across the line (move_point {move_point}): {flips}/50 flips");
|
||||
assert_eq!(flips, 0, "move_point {move_point}");
|
||||
}
|
||||
}
|
||||
|
||||
/// After a failed step the drag follows as far as the geometry can, and `reached` says how far:
|
||||
/// an arm's hand (reach 50) dragged in one step from 49 to 50.5 from its shoulder gets about
|
||||
/// two thirds of the way.
|
||||
#[test]
|
||||
fn a_drag_follows_up_to_where_its_step_fails() {
|
||||
let o = v(200.0, 100.0);
|
||||
let mut s = new_sketch();
|
||||
// Elbow bent so the hand is 49 from the shoulder, along +x.
|
||||
let c = (30.0f64 * 30.0 + 49.0 * 49.0 - 20.0 * 20.0) / (2.0 * 30.0 * 49.0);
|
||||
let elbow = o + v(30.0 * c, 30.0 * (1.0 - c * c).sqrt());
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [o.x + 49.0, o.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
assert_eq!(drag_steps(&s, "l2.end", [o.x + 49.0, o.y], [o.x + 50.5, o.y]), 1);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 49.0, o.y], "to": [o.x + 50.5, o.y]}));
|
||||
let reached = r["reached"].as_f64().unwrap_or(f64::NAN);
|
||||
eprintln!("arm hand past its reach in one step: {r}");
|
||||
assert!((1.0 / 1.5 - 1.0 / 16.0..=1.0 / 1.5).contains(&reached), "{r}");
|
||||
assert_eq!(r["sketch"]["solved"], json!(true), "{r}");
|
||||
}
|
||||
|
||||
/// The test-only step cap limits the steps a drag takes, not the plan tests read, and is
|
||||
/// restored when the capped code panics.
|
||||
#[test]
|
||||
fn the_step_cap_leaves_the_plan_and_is_restored() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 0], [120, 0]], "ids": ["l1"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 20}));
|
||||
let uncapped = super::drag::step_cap::get();
|
||||
let plan = drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0]);
|
||||
assert!(plan > 1);
|
||||
assert_eq!(super::drag::step_cap::with(1, || drag_steps(&s, "l1.end", [120.0, 0.0], [100.0, 20.0])), plan);
|
||||
assert_eq!(super::drag::step_cap::get(), uncapped);
|
||||
assert!(std::panic::catch_unwind(|| super::drag::step_cap::with(1, || panic!("capped"))).is_err());
|
||||
assert_eq!(super::drag::step_cap::get(), uncapped);
|
||||
}
|
||||
|
||||
/// Stored (counter-clockwise) sweep of an arc, degrees in [0, 360).
|
||||
fn sweep_deg(s: &Session, arc: &str) -> f64 {
|
||||
let sk = sketch(s);
|
||||
let Some(solvecraft_sketch::CurveKind::Arc { c, a, b }) = sk.curve_index(arc).map(|i| sk.curves[i].kind.clone()) else { panic!("no arc {arc}") };
|
||||
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
|
||||
((b - c).angle() - (a - c).angle()).rem_euclid(std::f64::consts::TAU).to_degrees()
|
||||
}
|
||||
|
||||
/// A free arc of radius 10 about a fixed centre (50, 50), from angle 0 sweeping `sweep`°.
|
||||
fn free_arc(sweep: f64) -> Session {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center": [50, 50], "start": [60, 50], "sweep": sweep, "id": "a1"}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["a1.center"]}));
|
||||
s
|
||||
}
|
||||
|
||||
fn on_arc(deg: f64) -> [f64; 2] {
|
||||
let t = deg.to_radians();
|
||||
[50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()]
|
||||
}
|
||||
|
||||
/// An arc's end dragged through its start never wraps the sweep (15° → 0° → 345°, or
|
||||
/// 330° → 360° → 30°): the arc stops short, in one call or in many.
|
||||
#[test]
|
||||
fn a_dragged_arc_end_never_wraps_through_its_start() {
|
||||
// One call, clockwise through the start: 90° → -60°.
|
||||
let mut s = free_arc(90.0);
|
||||
drag(&mut s, "a1.end", on_arc(90.0), on_arc(-60.0));
|
||||
let sw = sweep_deg(&s, "a1");
|
||||
eprintln!("free arc, CW one call 90° → -60°: sweep {sw:.1}°");
|
||||
assert!(sw > 0.0 && sw < 30.0, "CW one call: sweep {sw}°");
|
||||
// One call, counter-clockwise past a full turn: 330° → 390°.
|
||||
let mut s = free_arc(330.0);
|
||||
drag(&mut s, "a1.end", on_arc(330.0), on_arc(390.0));
|
||||
let sw = sweep_deg(&s, "a1");
|
||||
eprintln!("free arc, CCW one call 330° → 390°: sweep {sw:.1}°");
|
||||
assert!(sw > 330.0 && sw < 360.0, "CCW one call: sweep {sw}°");
|
||||
// 30° calls, clockwise: 90 → -60.
|
||||
let mut s = free_arc(90.0);
|
||||
let mut last = 90.0;
|
||||
let mut log = Vec::new();
|
||||
for deg in [60.0, 30.0, 0.0, -30.0, -60.0] {
|
||||
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
|
||||
last = deg;
|
||||
log.push(sweep_deg(&s, "a1"));
|
||||
}
|
||||
eprintln!("free arc, CW 30° calls: sweeps {log:.1?}");
|
||||
assert!(log.windows(2).all(|w| w[1] <= w[0] + 1e-9) && log.iter().all(|&x| x > 0.0), "CW calls: {log:?}");
|
||||
// 30° calls, counter-clockwise: 90 → 450.
|
||||
let mut s = free_arc(90.0);
|
||||
let mut last = 90.0;
|
||||
let mut log = Vec::new();
|
||||
for deg in (4..=15).map(|i| (i * 30) as f64) {
|
||||
drag(&mut s, "a1.end", on_arc(last), on_arc(deg));
|
||||
last = deg;
|
||||
log.push(sweep_deg(&s, "a1"));
|
||||
}
|
||||
eprintln!("free arc, CCW 30° calls: sweeps {log:.1?}");
|
||||
assert!(log.windows(2).all(|w| w[1] >= w[0] - 1e-9) && log.iter().all(|&x| x < 360.0), "CCW calls: {log:?}");
|
||||
assert!(log.last().is_some_and(|&x| x > 330.0), "CCW calls: {log:?}");
|
||||
}
|
||||
|
||||
/// A fillet (radius 5, tangent to both legs) whose second leg is dragged round the corner
|
||||
/// 90° → 174° → 14° → 90° in 2° calls stays inside the corner; with a 40 mm leg the leg turns
|
||||
/// over near 14° (the tangent length passes 40) and comes back on the way up.
|
||||
#[test]
|
||||
fn a_dragged_fillet_stays_in_its_corner() {
|
||||
for (leg, strict) in [(80.0, true), (40.0, false)] {
|
||||
let mut s = new_sketch();
|
||||
let o = v(100.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x + 40.0, o.y], [o.x, o.y], [o.x, o.y + leg]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "mode": "horizontal"}));
|
||||
let sk0 = sketch(&s);
|
||||
let arc = sk0.curves.iter().position(|c| matches!(c.kind, solvecraft_sketch::CurveKind::Arc { .. })).expect("fillet arc");
|
||||
let arc_id = sk0.curves[arc].id.clone();
|
||||
let inside = |s: &Session| {
|
||||
let sk = sketch(s);
|
||||
let side = |p: solvecraft_geom::Vec2, a: solvecraft_geom::Vec2, b: solvecraft_geom::Vec2| (b - a).cross(p - a).signum();
|
||||
let c = sk.center(arc).expect("centre");
|
||||
let (p0, k1, k2, p3) = (pt(s, "l1.start"), pt(s, "l1.end"), pt(s, "l2.start"), pt(s, "l2.end"));
|
||||
let r = sk.radius(arc).unwrap_or(0.0);
|
||||
side(c, p0, k1) == side(p3, p0, k1) && side(c, k2, p3) == side(p0, k2, p3) && (r - 5.0).abs() < 1e-6
|
||||
};
|
||||
assert!(inside(&s));
|
||||
let mut path: Vec<f64> = (45..=87).map(|i| (i * 2) as f64).collect();
|
||||
path.extend((7..=87).rev().map(|i| (i * 2) as f64));
|
||||
path.extend((8..=45).map(|i| (i * 2) as f64));
|
||||
let corner = o;
|
||||
let at = |deg: f64| {
|
||||
let t = deg.to_radians();
|
||||
[corner.x + leg * t.cos(), corner.y + leg * t.sin()]
|
||||
};
|
||||
let mut bad = Vec::new();
|
||||
let mut last = at(90.0);
|
||||
for deg in path {
|
||||
let to = at(deg);
|
||||
drag(&mut s, "l2.end", last, to);
|
||||
last = to;
|
||||
let sw = sweep_deg(&s, &arc_id);
|
||||
if !(inside(&s) && sw < 180.0) {
|
||||
bad.push(deg);
|
||||
}
|
||||
}
|
||||
if strict {
|
||||
assert!(bad.is_empty(), "leg {leg}: bad states at {bad:?}");
|
||||
}
|
||||
assert!(inside(&s) && sweep_deg(&s, &arc_id) < 180.0, "leg {leg}: did not recover (bad at {bad:?})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile `from` / `to` are refused and change nothing; a zero-length drag changes nothing.
|
||||
#[test]
|
||||
fn hostile_drags_change_nothing() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 20]]}));
|
||||
let l0 = snap(&s, "l1");
|
||||
for bad in [json!(f64::NAN), json!(f64::INFINITY), json!(1e300), json!(-1e300), json!("x"), json!([1.0]), json!(null)] {
|
||||
for (from, to) in [(json!([10, 10]), json!([bad.clone(), 0])), (json!([bad.clone(), 0]), json!([10, 10]))] {
|
||||
for e in ["l1", "l1.end"] {
|
||||
let r = s.execute("sketch.drag", &json!({"entity": e, "from": from.clone(), "to": to.clone()}));
|
||||
assert!(r.is_err(), "{e} {from} {to}: {r:?}");
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
}
|
||||
}
|
||||
assert!(s.execute("sketch.move_point", &json!({"point": "l1.end", "to": [bad.clone(), 0]})).is_err());
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
}
|
||||
drag(&mut s, "l1", [20.0, 10.0], [20.0, 10.0]);
|
||||
drag(&mut s, "l1.end", [40.0, 20.0], [40.0, 20.0]);
|
||||
run(&mut s, "sketch.move_point", json!({"point": "l1.end", "to": [40, 20]}));
|
||||
assert!(same(&snap(&s, "l1"), &l0));
|
||||
// A huge (but finite, in-range) jump on a constrained group is capped and never panics.
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
assert_eq!(drag_steps(&s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]), 64);
|
||||
drag(&mut s, "l1.end", [40.0, 20.0], [9.0e8, -9.0e8]);
|
||||
let (a, b) = (pt(&s, "l1.start"), pt(&s, "l1.end"));
|
||||
assert!(a.is_finite() && b.is_finite(), "{a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// The solves a drag of `entity` from `from` to `to` would take.
|
||||
fn drag_steps(s: &Session, entity: &str, from: [f64; 2], to: [f64; 2]) -> usize {
|
||||
let sk = sketch(s);
|
||||
let e = super::ent(&sk, entity).unwrap_or_else(|| panic!("no entity {entity}"));
|
||||
super::drag::drag_steps(&sk, e, v(from[0], from[1]), v(to[0], to[1]))
|
||||
}
|
||||
|
||||
/// Continuation costs nothing on a normal mouse frame: a short move next to the geometry is one
|
||||
/// held solve, as it was before; only a long one is split.
|
||||
#[test]
|
||||
fn a_short_drag_is_a_single_solve() {
|
||||
let mut s = new_sketch();
|
||||
// Ten dimensioned 20 × 10 rectangles, free to move (clear of the origin).
|
||||
for i in 0..10 {
|
||||
let x = 100.0 + 30.0 * i as f64;
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 20.0, 10]}));
|
||||
let c = curve_ids(&r);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [c[0]], "value": 20}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [c[1]], "value": 10}));
|
||||
}
|
||||
assert_eq!(drag_steps(&s, "l1.end", [120.0, 0.0], [120.5, 0.3]), 1);
|
||||
assert_eq!(drag_steps(&s, "l1", [110.0, 0.0], [110.5, 0.5]), 1);
|
||||
drag(&mut s, "l1.end", [120.0, 0.0], [120.5, 0.3]);
|
||||
assert!(pt(&s, "l1.end").dist(v(120.5, 0.3)) < 1e-9);
|
||||
// A long move (more than an eighth of the 10 mm side) is split into steps.
|
||||
assert!(drag_steps(&s, "l1.end", [120.5, 0.3], [140.0, -20.0]) > 1);
|
||||
drag(&mut s, "l1.end", [120.5, 0.3], [140.0, -20.0]);
|
||||
assert!(pt(&s, "l1.end").dist(v(140.0, -20.0)) < 1e-9);
|
||||
}
|
||||
|
||||
/// The drag group stops at fixed points, as the solve does: a tiny dimension on geometry that
|
||||
/// only shares a fixed point with the dragged geometry does not shrink its steps.
|
||||
#[test]
|
||||
fn the_drag_step_ignores_geometry_beyond_a_fixed_point() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
|
||||
let before = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.01, 100]], "ids": ["lb"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["lb"], "value": 0.01}));
|
||||
assert_eq!(drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]), before);
|
||||
assert_eq!(before, 1);
|
||||
// A free radius couples like a point: a line tangent to a circle whose centre is fixed
|
||||
// steps by the circle's radius (r 2: steps of 0.5 mm), its only link to the line.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [100, 100], "radius": 2, "connect": false}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["c1.center"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[90, 102], [110, 102]], "ids": ["lt"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.tangent", json!({"a": "c1", "b": "lt"}));
|
||||
assert_eq!(drag_steps(&s, "lt.end", [110.0, 102.0], [110.0, 105.0]), 6);
|
||||
}
|
||||
|
||||
/// The step counts every size the constraints hold, offsets along an axis too (the solver holds
|
||||
/// their size on either side), except a length between two points the drag holds: a dimensioned
|
||||
/// line slid by its middle keeps its length whatever the step.
|
||||
#[test]
|
||||
fn the_drag_step_counts_every_held_size_but_the_dragged_length() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100.5, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 0.5}));
|
||||
let steps = drag_steps(&s, "la", [100.25, 100.0], [105.25, 100.0]);
|
||||
eprintln!("0.5 mm line slid 5 mm: {steps} steps");
|
||||
assert_eq!(steps, 1);
|
||||
// B 0.05 mm right of A (horizontal dimension); A dragged 1 mm right in one call: B stays
|
||||
// right of A instead of jumping to the other side.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [100, 110]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[100.05, 120], [110, 120]], "ids": ["lb"], "connect": false}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la.start", "lb.start"], "type": "horizontal", "value": 0.05}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::DistanceX { .. })));
|
||||
let steps = drag_steps(&s, "la.start", [100.0, 100.0], [101.0, 100.0]);
|
||||
drag(&mut s, "la.start", [100.0, 100.0], [101.0, 100.0]);
|
||||
let (a, b) = (pt(&s, "la.start"), pt(&s, "lb.start"));
|
||||
eprintln!("1 mm drag with a 0.05 mm DistanceX: {steps} steps; A {a:?} B {b:?}");
|
||||
assert!(steps > 1);
|
||||
assert!(a.dist(v(101.0, 100.0)) < 1e-9 && (b.x - a.x - 0.05).abs() < 1e-6, "B went to the other side: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
/// A circle shares only its centre with a dragged line's end (coincident): the solve couples
|
||||
/// its centre, never its radius, so a tiny circle does not shrink the step.
|
||||
#[test]
|
||||
fn a_circle_centred_on_a_dragged_end_does_not_set_the_step() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[100, 100], [120, 100]], "ids": ["la"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["la.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["la"], "value": 20}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [120, 100], "radius": 0.01}));
|
||||
assert!(sketch(&s).constraints.iter().any(|c| matches!(c.kind, solvecraft_sketch::ConstraintKind::Coincident { .. })));
|
||||
let steps = drag_steps(&s, "la.end", [120.0, 100.0], [120.0, 101.0]);
|
||||
eprintln!("1 mm drag next to a 0.01 mm circle on the end: {steps} steps");
|
||||
assert_eq!(steps, 1);
|
||||
}
|
||||
|
||||
/// A one-call drag whose straight path crosses where the geometry cannot reach (an arm's hand
|
||||
/// through the hole round its shoulder) still lands on a cursor it can reach.
|
||||
#[test]
|
||||
fn a_drag_across_an_unreachable_middle_still_reaches_the_cursor() {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [o.x + 30.0, o.y], [o.x + 30.0, o.y + 20.0]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
// From (30, 20) to (-30, -20) about the shoulder: the path passes through it.
|
||||
let to = [o.x - 30.0, o.y - 20.0];
|
||||
let steps = drag_steps(&s, "l2.end", [o.x + 30.0, o.y + 20.0], to);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [o.x + 30.0, o.y + 20.0], "to": to}));
|
||||
eprintln!("arm hand across the hole: {steps} steps, {r}");
|
||||
assert!(steps > 1);
|
||||
assert!(pt(&s, "l2.end").dist(v(to[0], to[1])) < 1e-6, "{:?}", pt(&s, "l2.end"));
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
}
|
||||
|
||||
/// Past the unreachable stretch round its shoulder, an arm's drag keeps stepping (no one jump to
|
||||
/// the cursor), so a thin triangle hanging off the hand (it can turn over) keeps its side.
|
||||
#[test]
|
||||
fn a_drag_past_an_unreachable_stretch_keeps_stepping() {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
let elbow = o + v(19.702247520273062, 22.62347105662377);
|
||||
let hand = o + v(39.654744430607906, 21.245840103473398);
|
||||
let to = o + v(-28.82686238241851, -31.24580379872583);
|
||||
run(&mut s, "sketch.line", json!({"points": [[o.x, o.y], [elbow.x, elbow.y], [hand.x, hand.y]], "ids": ["l1", "l2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["l1.start"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l2"], "value": 20}));
|
||||
// A triangle with sides 3.9, 2 and 2 on the hand.
|
||||
let hand = pt(&s, "l2.end");
|
||||
let (q, r) = (hand + v(3.9, 0.0), hand + v(1.95, (4.0f64 - 1.95 * 1.95).sqrt()));
|
||||
run(&mut s, "sketch.line", json!({"points": [[hand.x, hand.y], [q.x, q.y], [r.x, r.y], [hand.x, hand.y]], "ids": ["t1", "t2", "t3"]}));
|
||||
for (l, value) in [("t1", 3.9), ("t2", 2.0), ("t3", 2.0)] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [l], "value": value}));
|
||||
}
|
||||
let side = |s: &Session| (pt(s, "t1.end") - pt(s, "l2.end")).cross(pt(s, "t2.end") - pt(s, "l2.end")).signum();
|
||||
let before = side(&s);
|
||||
let r = run(&mut s, "sketch.drag", json!({"entity": "l2.end", "from": [hand.x, hand.y], "to": [to.x, to.y]}));
|
||||
eprintln!("arm with a thin triangle past the hole: {r}, side {before} -> {}", side(&s));
|
||||
assert!(pt(&s, "l2.end").dist(to) < 1e-6, "{:?}", pt(&s, "l2.end"));
|
||||
assert_eq!(side(&s), before, "the triangle turned over");
|
||||
assert_eq!(r["reached"], json!(1.0));
|
||||
}
|
||||
|
||||
/// The sweep guard stops an arc's end crossing its start, never mere closeness: an arc at
|
||||
/// 359.99999° or 1e-7 rad still lets its own end and geometry joined to it be dragged.
|
||||
#[test]
|
||||
fn arcs_near_a_full_turn_or_nothing_can_still_be_dragged() {
|
||||
// (sweep, a nudge of the end towards the inside of the sweep that keeps it within 1e-6 rad
|
||||
// of the limit).
|
||||
for (sweep, nudge) in [(359.99999, -5e-7), (1e-7_f64.to_degrees(), 5e-7)] {
|
||||
let mut s = free_arc(sweep);
|
||||
// A dimensioned line hangs off the arc's end.
|
||||
let end = pt(&s, "a1.end");
|
||||
run(&mut s, "sketch.line", json!({"points": [[end.x, end.y], [end.x + 10.0, end.y + 10.0]], "ids": ["lc"], "connect": false}));
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": "a1.end", "b": "lc.start"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["lc"], "value": 200f64.sqrt()}));
|
||||
let sw0 = sweep_deg(&s, "a1");
|
||||
// The arc's own end.
|
||||
let t = (end - v(50.0, 50.0)).angle() + nudge;
|
||||
let to = [50.0 + 10.0 * t.cos(), 50.0 + 10.0 * t.sin()];
|
||||
drag(&mut s, "a1.end", [end.x, end.y], to);
|
||||
assert!(pt(&s, "a1.end").dist(v(to[0], to[1])) < 1e-9, "sweep {sweep}°: the arc's end did not move");
|
||||
// The joined line's far end, which pulls the arc's end along (inwards).
|
||||
let far = pt(&s, "lc.end");
|
||||
let target = v(far.x, far.y + 1e-5 * nudge.signum());
|
||||
drag(&mut s, "lc.end", [far.x, far.y], [target.x, target.y]);
|
||||
assert!(pt(&s, "lc.end").dist(target) < 1e-9, "sweep {sweep}°: the joined line did not move");
|
||||
assert!((sweep_deg(&s, "a1") - sw0).abs() < 1.0, "sweep {sweep}° became {}°", sweep_deg(&s, "a1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_constrained_feedback_is_per_entity() {
|
||||
let mut s = new_sketch();
|
||||
@@ -669,3 +1247,141 @@ fn pipe_and_sweep_follow_3d_sketch_curves() {
|
||||
assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && within(&hi[0], 50.0, 52.0) && within(&hi[2], 50.0, 52.0), "{p} {body}");
|
||||
assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}");
|
||||
}
|
||||
|
||||
/// Between two points, or on a line, that don't line up with an axis, an `auto` dimension takes
|
||||
/// its orientation from where its text goes: across from the two the distance between them,
|
||||
/// past that above or below them the horizontal distance, beside them the vertical one (#257).
|
||||
#[test]
|
||||
fn auto_dimension_orientation_follows_where_its_text_is_placed() {
|
||||
use solvecraft_sketch::ConstraintKind;
|
||||
// The ends of a 30 × 40 line: 50 long, and the slope keeps "across" and "past" distinct.
|
||||
let dimension = |end: [f64; 2], entities: Value, extra: Value| {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], end], "connect": false}));
|
||||
let mut p = json!({"entities": entities});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
let r = run(&mut s, "sketch.dimension", p);
|
||||
let sk = sketch(&s);
|
||||
let kind = sk.constraints.iter().find(|c| c.kind.is_dimension()).map(|c| c.kind.clone()).expect("a dimension");
|
||||
(kind, r["measured"].as_f64().unwrap_or(f64::NAN), sk)
|
||||
};
|
||||
let points = json!(["l1.start", "l1.end"]);
|
||||
for entities in [points.clone(), json!(["l1"])] {
|
||||
for (at, expected) in [
|
||||
([15.0, 60.0], "horizontal"),
|
||||
([15.0, -20.0], "horizontal"),
|
||||
([-30.0, 5.0], "vertical"),
|
||||
([55.0, 38.0], "vertical"),
|
||||
([-20.0, 25.0], "aligned"),
|
||||
([45.0, 10.0], "aligned"),
|
||||
([-20.0, -20.0], "aligned"),
|
||||
] {
|
||||
let (kind, measured, _) = dimension([30.0, 40.0], entities.clone(), json!({"text_at": at}));
|
||||
let (got, value) = match kind {
|
||||
ConstraintKind::DistanceX { .. } => ("horizontal", 30.0),
|
||||
ConstraintKind::DistanceY { .. } => ("vertical", 40.0),
|
||||
ConstraintKind::Distance { .. } | ConstraintKind::Length { .. } => ("aligned", 50.0),
|
||||
other => panic!("{entities} at {at:?}: {other:?}"),
|
||||
};
|
||||
assert_eq!(got, expected, "{entities} with its text at {at:?}");
|
||||
assert!((measured - value).abs() < 1e-9, "{entities} at {at:?} measured {measured}");
|
||||
}
|
||||
// No text, an explicit type, or two ends already on an axis keep today's dimension.
|
||||
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({}));
|
||||
assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}");
|
||||
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "distance", "text_at": [15, 60]}));
|
||||
assert!(matches!(kind, ConstraintKind::Distance { .. } | ConstraintKind::Length { .. }), "{kind:?}");
|
||||
let (kind, ..) = dimension([30.0, 40.0], entities.clone(), json!({"type": "vertical", "text_at": [15, 60]}));
|
||||
assert!(matches!(kind, ConstraintKind::DistanceY { .. }), "{kind:?}");
|
||||
let (kind, ..) = dimension([40.0, 0.0], json!(["l1.start", "l1.end"]), json!({"text_at": [60, 5]}));
|
||||
assert!(matches!(kind, ConstraintKind::Distance { .. }), "{kind:?}");
|
||||
}
|
||||
// The chosen dimension drives that coordinate: 24 across X, the 40 in Y kept.
|
||||
let (_, _, sk) = dimension([30.0, 40.0], points, json!({"text_at": [15, 60], "value": 24}));
|
||||
let end = sk.point(sk.resolve_point("l1.end").expect("end")).expect("end point");
|
||||
let start = sk.point(sk.resolve_point("l1.start").expect("start")).expect("start point");
|
||||
assert!(((end.x - start.x).abs() - 24.0).abs() < 1e-6 && ((end.y - start.y).abs() - 40.0).abs() < 1e-6, "{start:?} {end:?}");
|
||||
}
|
||||
|
||||
/// A two-link arm (B, elbow E, hand H) hanging off a rigid triangle A B C that only the cluster
|
||||
/// decomposition places: pinned at F by two of its corners and held at G by the third. Every
|
||||
/// line is dimensioned. Without decomposition budget the elbow's seed has nothing to build on.
|
||||
fn hung_arm() -> Session {
|
||||
let mut s = new_sketch();
|
||||
let o = v(200.0, 100.0);
|
||||
let at = |x: f64, y: f64| json!([o.x + x, o.y + y]);
|
||||
run(&mut s, "sketch.line", json!({"points": [at(0.0, 0.0), at(5.0, 15.0), at(20.0, 25.0), at(15.0, 8.0)], "ids": ["fa", "ab", "bc"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["bc.end", "ab.start"], "ids": ["ca"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["fa.start", "bc.end"], "ids": ["fc"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [at(40.0, 0.0), "ab.end"], "ids": ["gb"]}));
|
||||
run(&mut s, "sketch.line", json!({"points": ["ab.end", at(35.0, 40.0), at(50.0, 30.0)], "ids": ["be", "eh"]}));
|
||||
for id in ["fa", "ab", "bc", "ca", "fc", "gb", "be", "eh"] {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [id]}));
|
||||
}
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["fa.start", "gb.start"]}));
|
||||
s
|
||||
}
|
||||
|
||||
/// The elbow's side of the shoulder–hand line.
|
||||
fn hung_elbow_side(s: &Session) -> f64 {
|
||||
let b = pt(s, "ab.end");
|
||||
(pt(s, "eh.end") - b).cross(pt(s, "be.end") - b).signum()
|
||||
}
|
||||
|
||||
/// The hand of `hung_arm` swung by `angle` about the shoulder.
|
||||
fn hung_hand_target(s: &Session, angle: f64, scale: f64) -> solvecraft_geom::Vec2 {
|
||||
let (b, h) = (pt(s, "ab.end"), pt(s, "eh.end"));
|
||||
let r = h - b;
|
||||
let (c, si) = (angle.cos(), angle.sin());
|
||||
b + v(r.x * c - r.y * si, r.x * si + r.y * c) * scale
|
||||
}
|
||||
|
||||
/// A live drag whose seed runs out of decomposition budget takes the rest of the way in short
|
||||
/// steps (continuation keeps the elbow the seed no longer places).
|
||||
#[test]
|
||||
fn a_drag_out_of_decomposition_budget_takes_short_steps() {
|
||||
let start = hung_arm();
|
||||
let before = hung_elbow_side(&start);
|
||||
let h = pt(&start, "eh.end");
|
||||
let mut flips = 0;
|
||||
for k in [-3.0, -2.0, -1.0, 1.0, 2.0, 3.0] {
|
||||
let mut s = hung_arm();
|
||||
let to = hung_hand_target(&s, k * 0.7, 1.0);
|
||||
let planned = drag_steps(&s, "eh.end", [h.x, h.y], [to.x, to.y]);
|
||||
let r = super::drag::last_drag::with_frame_work(0, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [to.x, to.y], "live": true}))
|
||||
});
|
||||
let d = super::drag::last_drag::get();
|
||||
assert!(d.cut, "{d:?}");
|
||||
assert!(d.count > planned, "{planned} steps planned, {d:?}: the rest in short steps");
|
||||
assert!(pt(&s, "eh.end").dist(to) < 1e-6, "{r}");
|
||||
if hung_elbow_side(&s) != before {
|
||||
flips += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(flips, 0);
|
||||
}
|
||||
|
||||
/// All the held solves of one live frame, halvings included, share one decomposition budget:
|
||||
/// together they do at most a frame's work, and one solve's more.
|
||||
#[test]
|
||||
fn a_live_drag_shares_one_decomposition_budget() {
|
||||
let mut s = hung_arm();
|
||||
let h = pt(&s, "eh.end");
|
||||
let near = hung_hand_target(&s, 0.01, 1.0);
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [near.x, near.y], "live": true}));
|
||||
let one = super::drag::last_drag::get();
|
||||
assert!(one.count == 1 && one.work > 0 && !one.cut, "{one:?}");
|
||||
// Swung round and out past the arm's reach: many steps, then halvings.
|
||||
let mut s = hung_arm();
|
||||
let far = hung_hand_target(&s, 2.0, 1.5);
|
||||
let budget = 3 * one.work;
|
||||
super::drag::last_drag::with_frame_work(budget, || {
|
||||
run(&mut s, "sketch.drag", json!({"entity": "eh.end", "from": [h.x, h.y], "to": [far.x, far.y], "live": true}))
|
||||
});
|
||||
let d = super::drag::last_drag::get();
|
||||
assert!(d.count > 6, "{d:?}");
|
||||
assert!(d.work <= budget + one.work, "{d:?} over a budget of {budget}");
|
||||
}
|
||||
|
||||
@@ -217,6 +217,19 @@ pub struct Session {
|
||||
/// commands are undone however long the undo history is.
|
||||
pub(crate) checkpoints: Vec<Checkpoint>,
|
||||
next_checkpoint: u64,
|
||||
/// Full model evaluations (`refresh`), for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) evaluations: usize,
|
||||
}
|
||||
|
||||
/// A sketch changed by one frame of an interactive gesture (`live: true`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LiveEdit {
|
||||
pub sketch: u64,
|
||||
/// What the frame's solve found: a full solve's report, or, when the sketch was solved
|
||||
/// without a rank analysis, its last report's degrees of freedom and determined flags (a
|
||||
/// drag does not change them; the evaluation at the gesture's end brings them up to date).
|
||||
pub report: solvecraft_sketch::SolveReport,
|
||||
}
|
||||
|
||||
/// The design, active sketch, pending positions and undo history at an `edit.checkpoint`.
|
||||
@@ -302,6 +315,8 @@ impl Session {
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: Visibility::default(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +375,7 @@ impl Session {
|
||||
if let Some(inverse) = solvecraft_doc::mat_inverse(&first) {
|
||||
let delta = solvecraft_doc::mat_mul(&self.active_component_frame(), &inverse);
|
||||
for sketch in w.sketches.iter_mut().filter(|sk| self.doc.feature(sk.feature).is_some_and(|f| f.component == self.active_component)) {
|
||||
let sketch = Arc::make_mut(sketch);
|
||||
let plane = sketch.plane;
|
||||
if let Some(mapped) = solvecraft_geom::Plane::new(
|
||||
solvecraft_doc::apply_point(&delta, plane.origin),
|
||||
@@ -394,6 +410,10 @@ impl Session {
|
||||
|
||||
pub fn refresh(&mut self) {
|
||||
self.model.evaluate(&self.doc);
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.evaluations += 1;
|
||||
}
|
||||
self.revision += 1;
|
||||
self.prune_occurrence_state();
|
||||
// Joints follow the geometry their origins snap to.
|
||||
@@ -407,10 +427,65 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the sketch a live frame changed, leaving the features after it as they were last
|
||||
/// evaluated (marked stale); a full `refresh` when the model cannot show it that way.
|
||||
fn refresh_live(&mut self, edit: LiveEdit) {
|
||||
let sketch = self.doc.sketch(edit.sketch).ok().cloned();
|
||||
if sketch.is_some_and(|sketch| self.model.show_live_sketch(edit.sketch, sketch, edit.report)) {
|
||||
self.revision += 1;
|
||||
} else {
|
||||
self.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketches whose last evaluation solved, each with its own parameter-driven values
|
||||
/// (taken before a value edit, for [`Session::settle_value_edit`]).
|
||||
pub(crate) fn solved_sketches(&self) -> SketchValues {
|
||||
self.model
|
||||
.results
|
||||
.iter()
|
||||
.filter_map(|r| r.state.sketch(r.id).filter(|k| k.report.keeps_orientation()).map(|k| (r.id, own_values(&k.sketch))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// After the document's values changed (parameters, a configuration, the units): evaluate,
|
||||
/// and refuse the edit when a sketch that solved before no longer does, or turns over,
|
||||
/// because its own parameter-driven values changed (the command fails, so `execute`
|
||||
/// restores the document's values and geometry). A sketch broken by a change upstream
|
||||
/// (a projected or linked edge moved) is not the edit's to refuse: it carries a warning,
|
||||
/// as before. Otherwise store each sketch whose values changed as solved, so the next edit
|
||||
/// continues from it (see `Model::adopt_solved_sketches`).
|
||||
pub(crate) fn settle_value_edit(&mut self, solved_before: &SketchValues) -> Result<()> {
|
||||
self.refresh();
|
||||
for r in &self.model.results {
|
||||
let (Some(before), Some(k)) = (solved_before.get(&r.id), r.state.sketch(r.id)) else { continue };
|
||||
if !k.report.keeps_orientation() && own_values(&k.sketch) != *before {
|
||||
let why = k.report.failure(|f| format!("its constraints would conflict ({f})"));
|
||||
return Err(EngineError::Other(format!("sketch `{}`: {why}; the values were not changed", r.name)));
|
||||
}
|
||||
}
|
||||
// Checked first: storing makes the document unique (a copy when the undo step shares it).
|
||||
if self.model.edited_sketches(&self.doc).next().is_some() {
|
||||
self.model.adopt_solved_sketches(Arc::make_mut(&mut self.doc));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step
|
||||
/// when they change the document; a failing or panicking command leaves it unchanged.
|
||||
///
|
||||
/// A command that takes `live` ([`CommandSpec::live`]) run with `live: true` is one frame of
|
||||
/// an interactive gesture: the model shows its sketch's new solution and keeps every later
|
||||
/// feature's last result (stale) instead of evaluating them. Any other command that reads
|
||||
/// the model ([`CommandSpec::reads_model`]) first evaluates a stale model in full, so the
|
||||
/// gesture's end (`sketch.drag_end`, or whatever runs next after an abandoned gesture) brings
|
||||
/// the model up to date before anything reads it.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
|
||||
if live.is_none() && spec.reads_model && self.model.is_stale() {
|
||||
self.refresh();
|
||||
}
|
||||
(spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?;
|
||||
let before = Snapshot {
|
||||
label: spec.label.to_string(),
|
||||
@@ -425,7 +500,10 @@ impl Session {
|
||||
let (_nested, outermost) = frames::Nested::enter(spec.id);
|
||||
let mapped = if outermost { frames::to_local(self, spec.id, params) } else { None };
|
||||
let params = mapped.as_ref().unwrap_or(params);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match live {
|
||||
Some(run) => run(self, params),
|
||||
None => (spec.run)(self, params).map(|v| (v, None)),
|
||||
}));
|
||||
let r = match r {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
@@ -435,7 +513,7 @@ impl Session {
|
||||
};
|
||||
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
|
||||
match r {
|
||||
Ok(v) => {
|
||||
Ok((v, live_edit)) => {
|
||||
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
|
||||
if changed {
|
||||
if spec.undoable {
|
||||
@@ -445,7 +523,10 @@ impl Session {
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
self.refresh();
|
||||
match live_edit {
|
||||
Some(edit) => self.refresh_live(edit),
|
||||
None => self.refresh(),
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
@@ -548,6 +629,8 @@ impl Session {
|
||||
checkpoints: Vec::new(),
|
||||
next_checkpoint: 1,
|
||||
visibility: self.visibility.clone(),
|
||||
#[cfg(test)]
|
||||
evaluations: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -686,3 +769,12 @@ mod tests;
|
||||
mod thread_tests;
|
||||
#[cfg(test)]
|
||||
mod world_placement_cache_tests;
|
||||
|
||||
/// Per sketch feature: its parameter-driven dimensions' values (see [`own_values`]).
|
||||
pub(crate) type SketchValues = std::collections::BTreeMap<u64, std::collections::BTreeMap<String, f64>>;
|
||||
|
||||
/// The values of a sketch's driving dimensions that come from parameters: what a value edit
|
||||
/// changes in it (as opposed to geometry it projects or links from upstream).
|
||||
fn own_values(sk: &solvecraft_sketch::Sketch) -> std::collections::BTreeMap<String, f64> {
|
||||
sk.constraints.iter().filter(|c| c.param.is_some() && !c.driven).filter_map(|c| Some((c.id.clone(), c.kind.value()?))).collect()
|
||||
}
|
||||
|
||||
+154
-2
@@ -145,6 +145,7 @@ mod component_frames;
|
||||
mod mirror_planes;
|
||||
mod primitive_points;
|
||||
mod robustness;
|
||||
mod xz_plane;
|
||||
|
||||
/// Plan area a round of radius `r` fills at the concave corner `x` where circles (c1, r1) and
|
||||
/// (c2, r2) cross: the triangle of its contacts and the corner, less the three circular segments.
|
||||
@@ -209,6 +210,42 @@ fn fillet_the_edges_between_two_overlapping_cylinders() {
|
||||
assert!(rel(v, expect) < 2e-4, "{v} vs {expect}");
|
||||
}
|
||||
|
||||
/// The top outline of two overlapping cylinders whose concave edges were rounded: the walls of
|
||||
/// the rounds meet the cylinders along the rolling-ball contact lines, straight but not lines.
|
||||
#[test]
|
||||
fn fillet_and_chamfer_the_top_loop_of_rounded_overlapping_cylinders() {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
let (big, round, h) = (25.0_f64, 5.0_f64, 10.0);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": big}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [-big, 0], "radius": big}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": h, "profiles": [0, 1, 2]}));
|
||||
// Plan view: ball centres at x = −12.5, |y| = yb; each round's arc turns by th, each big arc by
|
||||
// TAU − 2·a1, and the loop turns by TAU in all.
|
||||
let half = big / 2.0;
|
||||
let yb = ((big + round).powi(2) - half * half).sqrt();
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[-half, half * 3f64.sqrt(), 5], [-half, -half * 3f64.sqrt(), 5]], "radius": round}));
|
||||
let (a1, th) = (yb.atan2(half), 2.0 * (half / (big + round)).asin());
|
||||
let base = volume(&mut s);
|
||||
let top = [[big, 0.0, h], [-2.0 * big, 0.0, h], [-half, yb - round, h], [-half, round - yb, h]];
|
||||
// Removed volume by Pappus: the corner's section swept along the loop at its centroid's offset.
|
||||
let removed = |area: f64, xb: f64| area * (2.0 * (TAU - 2.0 * a1) * (big - xb) + 2.0 * th * (round + xb));
|
||||
run(&mut s, "solid.fillet", json!({"edges": top, "radius": 2}));
|
||||
let r: f64 = 2.0;
|
||||
let expect = base - removed(r * r * (1.0 - PI / 4.0), (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)) * r);
|
||||
let v = volume(&mut s);
|
||||
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
assert_eq!(m["bodies"][0]["kernel"]["faces"].as_u64(), Some(10), "{m}");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": top, "distance": 2}));
|
||||
let expect = base - removed(2.0, 2.0 / 3.0);
|
||||
let v = volume(&mut s);
|
||||
assert!((v - expect).abs() < 1.5, "{v} vs {expect}");
|
||||
}
|
||||
|
||||
/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole.
|
||||
#[test]
|
||||
fn box_fillet_cut_part() {
|
||||
@@ -1641,8 +1678,10 @@ fn cached_evaluation_matches_from_scratch() {
|
||||
let before = hits(&s);
|
||||
run(&mut s, id, p.clone());
|
||||
same(&s, &format!("step {step}: {id} {p}"));
|
||||
if step == 1 {
|
||||
assert!(hits(&s) > before, "setting the width back reuses the earlier results");
|
||||
// Setting the width back solves the sketch again from its current state; the
|
||||
// features after it see the same geometry and reuse their results. So does undo.
|
||||
if step == 1 || step == 3 {
|
||||
assert!(hits(&s) > before, "step {step} reuses the earlier results");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2638,3 +2677,116 @@ fn feature_params_docs_list_shared_keys() {
|
||||
assert!(params(id).contains("targets?"), "{id}: {}", params(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pattern_repeats_a_fillet_on_the_edges_it_found() {
|
||||
// #219: a filleted pocket patterned along the plate, its height from a parameter. A taller
|
||||
// pocket moves its far corner edges away from the points picked for the fillet; the pattern
|
||||
// repeats the edges the fillet found (by name), not those points.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "n", "expression": "14", "unit": ""}));
|
||||
run(&mut s, "parameters.add", json!({"name": "h", "expression": "(419.1 mm - 3.175 mm - (n - 1) * 3.175 mm) / n", "unit": "mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [150, 422.275]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 19.05, "name": "Plate"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 19.05, "name": "PocketPlane"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "PocketPlane"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [3.175, 3.175], "p1": [146.825, 29.9357]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "horizontal", "value": "3.175"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["origin", "p1"], "type": "vertical", "value": "3.175"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "type": "length", "value": "143.65"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l4"], "type": "length", "value": "h"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 12.7, "direction": "negative", "operation": "cut", "name": "Pocket"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({"edges": [[3.175, 3.175, 12.700000000000001], [146.825, 3.175, 12.700000000000001], [146.825, 29.9357, 12.700000000000001], [3.175, 29.9357, 12.700000000000001]], "radius": 3.2}),
|
||||
);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.pattern.rectangular",
|
||||
json!({"features": ["Pocket", "Fillet1"], "dir1": [0, 1, 0], "count1": "n", "spacing1": "h + 3.175 mm"}),
|
||||
);
|
||||
let r = run(&mut s, "parameters.change", json!({"name": "n", "expression": "10"}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let (n, h) = (10.0, (419.1 - 3.175 - 9.0 * 3.175) / 10.0);
|
||||
let pocket = 143.65 * h * 12.7 - 4.0 * (1.0 - PI / 4.0) * 3.2 * 3.2 * 12.7;
|
||||
let expect = 150.0 * 422.275 * 19.05 - n * pocket;
|
||||
assert!(rel(volume(&mut s), expect) < 1e-5, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A copy added by raising the count after a rename takes the next number, not the freed one (#372).
|
||||
#[test]
|
||||
#[ignore = "fails until #372 is fixed"]
|
||||
fn raising_a_pattern_count_after_a_rename_numbers_the_new_copy_after_the_others() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20, "name": "Pat"}));
|
||||
run(&mut s, "body.rename", json!({"body": "X (2)", "name": "Second"}));
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "4"}}}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["X", "Second", "X (3)", "X (4)"]);
|
||||
let fourth = run(&mut s, "inspect.measure", json!({"bodies": ["X (4)"]}));
|
||||
assert_eq!(fourth["bodies"][0]["bbox"]["min"][0].as_f64().map(f64::round), Some(60.0), "{fourth}");
|
||||
}
|
||||
|
||||
/// A profile added to the sketch after a named multi-profile extrude is named after `body_name`
|
||||
/// too (#373).
|
||||
#[test]
|
||||
fn a_profile_added_later_is_named_after_body_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
for x in [0, 10, 20] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "R"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 0], "p1": [35, 30]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Ribs", "Ribs (2)", "Ribs (3)", "Ribs (4)"]);
|
||||
}
|
||||
|
||||
/// The reply names the bodies the feature created, apart from the ones already there (#374).
|
||||
#[test]
|
||||
#[ignore = "fails until #374 is fixed"]
|
||||
fn extrude_reply_lists_the_bodies_it_created() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [0, 50, 0], "operation": "new_body", "body_name": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
for x in [0, 10, 20] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let r = run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
|
||||
assert_eq!(r["created_bodies"], json!(["Ribs", "Ribs (2)", "Ribs (3)"]), "{r}");
|
||||
}
|
||||
|
||||
/// A design saved before format 14 names the bodies past its stored names `BodyN`, as it did when
|
||||
/// it was saved, so later features that name them still find them (#373).
|
||||
#[test]
|
||||
fn a_design_saved_before_format_14_keeps_generic_names_past_body_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "R"}));
|
||||
for x in [0, 10, 20] {
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 5, 30]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "R", "distance": "10", "operation": "new_body", "body_name": "Ribs"}));
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solvecraft/13");
|
||||
let extrude = legacy["features"].as_array_mut().unwrap().iter_mut().find(|f| f["type"] == "extrude").unwrap();
|
||||
extrude["body_names"] = json!(["Ribs"]);
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-373-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let design = dir.join("ribs.solvecraft");
|
||||
std::fs::write(&design, legacy.to_string()).unwrap();
|
||||
let mut old = Session::default();
|
||||
run(&mut old, "doc.open", json!({"path": design.to_string_lossy()}));
|
||||
let names: Vec<String> = old.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Ribs", "Body1", "Body2"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! The XZ origin plane (#78): sketch x = +X, y = −Z, normal +Y, so a positive extrude from XZ
|
||||
//! (or from a plane offset from it) goes +Y. Designs saved before format 6 used y = +Z and
|
||||
//! normal −Y; opening one keeps its geometry exactly where it was saved.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::run;
|
||||
use crate::*;
|
||||
|
||||
/// A 10 × 5 rectangle on XZ extruded 3, and a 4 × 4 square on a plane offset 7 from XZ
|
||||
/// extruded 2.
|
||||
fn build(s: &mut Session) {
|
||||
run(s, "sketch.create", json!({"plane": "XZ", "name": "Base"}));
|
||||
run(s, "sketch.line", json!({"points": [[0, 0], [10, 0], [10, 5], [0, 5]], "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Base", "distance": 3, "body_name": "Slab"}));
|
||||
run(s, "construct.plane.offset", json!({"base": "XZ", "offset": "7 mm", "name": "Shelf"}));
|
||||
run(s, "sketch.create", json!({"plane": "Shelf", "name": "Top"}));
|
||||
run(s, "sketch.line", json!({"points": [[20, 1], [24, 1], [24, 5], [20, 5]], "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": "Top", "distance": 2, "body_name": "Post", "operation": "new"}));
|
||||
}
|
||||
|
||||
/// Volume and world bounding box (min, max) of each body.
|
||||
fn bodies(s: &mut Session) -> Vec<(String, f64, [f64; 3], [f64; 3])> {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let v3 = |v: &Value| -> [f64; 3] { [0, 1, 2].map(|i| v[i].as_f64().unwrap()) };
|
||||
m["bodies"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|b| (b["name"].as_str().unwrap().to_string(), b["volume_mm3"].as_f64().unwrap(), v3(&b["bbox"]["min"]), v3(&b["bbox"]["max"])))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn close(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
fn expect(got: &[(String, f64, [f64; 3], [f64; 3])], want: &[(&str, f64, [f64; 3], [f64; 3])]) {
|
||||
assert_eq!(got.len(), want.len(), "{got:?}");
|
||||
for ((name, vol, min, max), (wname, wvol, wmin, wmax)) in got.iter().zip(want) {
|
||||
assert_eq!(name, wname);
|
||||
assert!((vol - wvol).abs() < 1e-6 * wvol, "{name}: volume {vol} vs {wvol}");
|
||||
assert!(close(*min, *wmin) && close(*max, *wmax), "{name}: bbox {min:?}..{max:?}, expected {wmin:?}..{wmax:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_extrude_from_xz_goes_plus_y() {
|
||||
let mut s = Session::default();
|
||||
build(&mut s);
|
||||
expect(
|
||||
&bodies(&mut s),
|
||||
&[
|
||||
// Sketch y = −Z; extrude along +Y.
|
||||
("Slab", 150.0, [0.0, 0.0, -5.0], [10.0, 3.0, 0.0]),
|
||||
// The offset plane sits at Y = +7 and its extrude continues to +Y.
|
||||
("Post", 32.0, [20.0, 7.0, -5.0], [24.0, 9.0, -1.0]),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_5_xz_design_opens_where_it_was_saved() {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-xz78-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("old-xz.solvecraft");
|
||||
let mut s = Session::default();
|
||||
build(&mut s);
|
||||
run(&mut s, "file.save_as", json!({"path": path.to_string_lossy()}));
|
||||
// Stamp the file as format 5: its XZ references then mean the old frame (sketch y = +Z,
|
||||
// normal −Y), which is what a build before format 6 wrote and evaluated.
|
||||
let mut v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert!(v.to_string().contains("\"XZ\""), "the saved design names the XZ plane");
|
||||
v["format"] = Value::from("solvecraft/5");
|
||||
std::fs::write(&path, v.to_string()).unwrap();
|
||||
|
||||
let mut old = Session::default();
|
||||
run(&mut old, "doc.open", json!({"path": path.to_string_lossy()}));
|
||||
let want = [
|
||||
// The old frame: the same sketch coordinates land at +Z and the extrude goes −Y.
|
||||
("Slab", 150.0, [0.0, -3.0, 0.0], [10.0, 0.0, 5.0]),
|
||||
("Post", 32.0, [20.0, -9.0, 1.0], [24.0, -7.0, 5.0]),
|
||||
];
|
||||
expect(&bodies(&mut old), &want);
|
||||
|
||||
// Saving the migrated design writes the current format and keeps the geometry.
|
||||
let resaved = dir.join("resaved.solvecraft");
|
||||
run(&mut old, "file.save_as", json!({"path": resaved.to_string_lossy()}));
|
||||
let text = std::fs::read_to_string(&resaved).unwrap();
|
||||
assert!(text.contains(&format!("\"solvecraft/{}\"", solvecraft_doc::format::FORMAT)), "resaved at the current format");
|
||||
let mut again = Session::default();
|
||||
run(&mut again, "doc.open", json!({"path": resaved.to_string_lossy()}));
|
||||
expect(&bodies(&mut again), &want);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,812 @@
|
||||
//! B-spline machinery shared by NURBS and T-spline surfaces: homogeneous points, the
|
||||
//! univariate basis (Cox–de Boor on half-open intervals, with derivatives), Boehm knot
|
||||
//! insertion, knot insertion into a single basis function, Bézier extraction, and a tensor
|
||||
//! NURBS surface.
|
||||
//!
|
||||
//! References: Piegl & Tiller, *The NURBS Book* (2nd ed.) — §2.2 (basis definition, eq. 2.5),
|
||||
//! §2.3 (derivatives, eq. 2.9), A2.1 (span search), §5.2 (knot insertion, eq. 5.10 / A5.1),
|
||||
//! §1.4 (de Casteljau); Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline
|
||||
//! simplification and local refinement", §3 (knot insertion into one blending function).
|
||||
//!
|
||||
//! Input is hostile: every constructor checks finiteness and sizes and returns
|
||||
//! [`SplineError`] instead of panicking; no function here indexes with an unchecked number.
|
||||
|
||||
use std::ops::{Add, AddAssign, Mul, Sub};
|
||||
|
||||
use crate::Vec3;
|
||||
|
||||
/// Highest polynomial degree accepted in either direction.
|
||||
pub const MAX_SPLINE_DEGREE: usize = 7;
|
||||
/// Most knots accepted in one knot vector.
|
||||
pub const MAX_KNOTS: usize = 1 << 14;
|
||||
/// Most control points accepted in one tensor surface.
|
||||
pub const MAX_SURFACE_POINTS: usize = 1 << 20;
|
||||
|
||||
/// Why a spline could not be built or used.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SplineError {
|
||||
#[error("invalid spline: {0}")]
|
||||
Invalid(String),
|
||||
#[error("not supported yet: {0}")]
|
||||
Unsupported(String),
|
||||
#[error("spline too large: {0}")]
|
||||
TooLarge(String),
|
||||
}
|
||||
|
||||
pub type SplineResult<T> = Result<T, SplineError>;
|
||||
|
||||
pub(crate) fn invalid<T>(msg: impl Into<String>) -> SplineResult<T> {
|
||||
Err(SplineError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
/// A homogeneous control point `(x·w, y·w, z·w, w)`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct HPoint {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub z: f64,
|
||||
pub w: f64,
|
||||
}
|
||||
|
||||
impl HPoint {
|
||||
pub const ZERO: HPoint = HPoint { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
|
||||
|
||||
/// The point `p` with weight `w`.
|
||||
pub fn weighted(p: Vec3, w: f64) -> HPoint {
|
||||
HPoint { x: p.x * w, y: p.y * w, z: p.z * w, w }
|
||||
}
|
||||
/// The Cartesian point, if the weight is usable.
|
||||
pub fn project(self) -> Option<Vec3> {
|
||||
let p = Vec3::new(self.x / self.w, self.y / self.w, self.z / self.w);
|
||||
(self.w.abs() > f64::MIN_POSITIVE && p.is_finite()).then_some(p)
|
||||
}
|
||||
/// The weighted coordinates `(x·w, y·w, z·w)`.
|
||||
pub fn xyz(self) -> Vec3 {
|
||||
Vec3::new(self.x, self.y, self.z)
|
||||
}
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.x.is_finite() && self.y.is_finite() && self.z.is_finite() && self.w.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
impl Add for HPoint {
|
||||
type Output = HPoint;
|
||||
fn add(self, o: HPoint) -> HPoint {
|
||||
HPoint { x: self.x + o.x, y: self.y + o.y, z: self.z + o.z, w: self.w + o.w }
|
||||
}
|
||||
}
|
||||
impl Sub for HPoint {
|
||||
type Output = HPoint;
|
||||
fn sub(self, o: HPoint) -> HPoint {
|
||||
HPoint { x: self.x - o.x, y: self.y - o.y, z: self.z - o.z, w: self.w - o.w }
|
||||
}
|
||||
}
|
||||
impl Mul<f64> for HPoint {
|
||||
type Output = HPoint;
|
||||
fn mul(self, k: f64) -> HPoint {
|
||||
HPoint { x: self.x * k, y: self.y * k, z: self.z * k, w: self.w * k }
|
||||
}
|
||||
}
|
||||
impl AddAssign for HPoint {
|
||||
fn add_assign(&mut self, o: HPoint) {
|
||||
*self = *self + o;
|
||||
}
|
||||
}
|
||||
|
||||
/// A parameter direction: along s (u, the first) or along t (v, the second).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Dir {
|
||||
S,
|
||||
T,
|
||||
}
|
||||
|
||||
impl Dir {
|
||||
/// `s` for [`Dir::S`], `t` for [`Dir::T`].
|
||||
pub fn pick<X>(self, s: X, t: X) -> X {
|
||||
match self {
|
||||
Dir::S => s,
|
||||
Dir::T => t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Homogeneous control points from finite points and positive weights, one each.
|
||||
pub fn weighted_points(points: &[Vec3], weights: &[f64]) -> SplineResult<Vec<HPoint>> {
|
||||
if points.len() != weights.len() {
|
||||
return invalid("one weight per control point");
|
||||
}
|
||||
if points.iter().any(|p| !p.is_finite()) {
|
||||
return invalid("a control point is not finite");
|
||||
}
|
||||
if weights.iter().any(|w| !(w.is_finite() && *w > 0.0)) {
|
||||
return invalid("weights must be positive numbers");
|
||||
}
|
||||
Ok(points.iter().zip(weights).map(|(p, w)| HPoint::weighted(*p, *w)).collect())
|
||||
}
|
||||
|
||||
/// Which one-sided limit a basis evaluation takes at a knot. Basis functions live on half-open
|
||||
/// spans `[uᵢ, uᵢ₊₁)` ([`Side::Right`]); at the right end of a domain the left limit on
|
||||
/// `(uᵢ, uᵢ₊₁]` ([`Side::Left`]) keeps the closing span.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
Right,
|
||||
Left,
|
||||
}
|
||||
|
||||
/// Check a knot vector: finite, non-decreasing, not too long.
|
||||
pub fn check_knots(knots: &[f64]) -> SplineResult<()> {
|
||||
if knots.len() > MAX_KNOTS {
|
||||
return Err(SplineError::TooLarge(format!("{} knots (at most {MAX_KNOTS})", knots.len())));
|
||||
}
|
||||
if knots.iter().any(|k| !k.is_finite()) {
|
||||
return invalid("a knot is not a finite number");
|
||||
}
|
||||
if knots.windows(2).any(|w| matches!(w, [a, b] if b < a)) {
|
||||
return invalid("knots must not decrease");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One B-spline basis function of degree `p = knots.len() − 2` given by its local knot vector
|
||||
/// `[ξ₀ … ξₚ₊₁]`, and its derivatives up to order `n`: element `k` is the `k`-th derivative at
|
||||
/// `u`. Cox–de Boor recursion (NURBS Book eq. 2.5) with the 0/0 = 0 convention, derivatives by
|
||||
/// eq. 2.9 applied repeatedly (`dᵏNᵢ,ₚ = p·(dᵏ⁻¹Nᵢ,ₚ₋₁/(uᵢ₊ₚ−uᵢ) − dᵏ⁻¹Nᵢ₊₁,ₚ₋₁/(uᵢ₊ₚ₊₁−uᵢ₊₁))`).
|
||||
/// A knot vector shorter than two knots gives zeros.
|
||||
pub fn local_basis(knots: &[f64], u: f64, side: Side, n: usize) -> Vec<f64> {
|
||||
let mut out = vec![0.0; n + 1];
|
||||
let Some(p) = knots.len().checked_sub(2) else { return out };
|
||||
if p > MAX_SPLINE_DEGREE || !u.is_finite() {
|
||||
return out;
|
||||
}
|
||||
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
|
||||
let ratio = |num: f64, den: f64| if den > 0.0 { num / den } else { 0.0 };
|
||||
// table[k][j]: k-th derivative of N_{j,q} for the current degree q (j = 0..=p−q), updated
|
||||
// in place: k descending (level k reads level k−1 of the previous degree), j ascending
|
||||
// (N_{j,q} reads N_{j+1,q−1}, not yet overwritten). Derivatives above p are zero.
|
||||
const W: usize = MAX_SPLINE_DEGREE + 1;
|
||||
let levels = n.min(p);
|
||||
let mut table = [[0.0f64; W]; W];
|
||||
for (j, slot) in table[0].iter_mut().enumerate().take(p + 1) {
|
||||
let (a, b) = (kn(j), kn(j + 1));
|
||||
let inside = match side {
|
||||
Side::Right => a <= u && u < b,
|
||||
Side::Left => a < u && u <= b,
|
||||
};
|
||||
*slot = if inside { 1.0 } else { 0.0 };
|
||||
}
|
||||
for q in 1..=p {
|
||||
for k in (0..=levels).rev() {
|
||||
for j in 0..=p - q {
|
||||
let (d1, d2) = (kn(j + q) - kn(j), kn(j + q + 1) - kn(j + 1));
|
||||
let at = |k: usize, j: usize| table.get(k).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
let v = if k == 0 {
|
||||
ratio(u - kn(j), d1) * at(0, j) + ratio(kn(j + q + 1) - u, d2) * at(0, j + 1)
|
||||
} else {
|
||||
q as f64 * (ratio(at(k - 1, j), d1) - ratio(at(k - 1, j + 1), d2))
|
||||
};
|
||||
if let Some(slot) = table.get_mut(k).and_then(|r| r.get_mut(j)) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (k, o) in out.iter_mut().enumerate().take(levels + 1) {
|
||||
*o = table.get(k).map_or(0.0, |r| r[0]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The coefficients `(c₁, c₂)` of knot insertion into one basis function: inserting `k`
|
||||
/// (with `ξ₀ ≤ k ≤ ξₚ₊₁`) into `N[ξ₀…ξₚ₊₁]` gives `c₁·N[first p+2 merged knots] +
|
||||
/// c₂·N[last p+2 merged knots]` (Sederberg et al. 2004, §3; the single-function case of the
|
||||
/// NURBS Book eq. 5.10). `None` when `k` is outside the support or the knots are too few.
|
||||
pub fn split_basis(knots: &[f64], k: f64) -> Option<(f64, f64)> {
|
||||
let p = knots.len().checked_sub(2)?;
|
||||
let (x0, x1, xp, xl) = (*knots.first()?, *knots.get(1)?, *knots.get(p)?, *knots.get(p + 1)?);
|
||||
if !(k >= x0 && k <= xl) {
|
||||
return None;
|
||||
}
|
||||
let c1 = if k >= xp { 1.0 } else { (k - x0) / (xp - x0) };
|
||||
let c2 = if k <= x1 { 1.0 } else { (xl - k) / (xl - x1) };
|
||||
Some((c1, c2))
|
||||
}
|
||||
|
||||
/// The two knot vectors of a split basis function (see [`split_basis`]): `k` merged into
|
||||
/// `knots` in order, then the first and the last `knots.len()` entries.
|
||||
pub fn split_knots<T: Copy + PartialOrd>(knots: &[T], k: T) -> [Vec<T>; 2] {
|
||||
let at = knots.iter().position(|&x| x > k).unwrap_or(knots.len());
|
||||
let mut merged = knots.to_vec();
|
||||
merged.insert(at, k);
|
||||
let n = knots.len();
|
||||
[merged.get(..n).map(<[T]>::to_vec).unwrap_or_default(), merged.get(1..).map(<[T]>::to_vec).unwrap_or_default()]
|
||||
}
|
||||
|
||||
/// The Bernstein coefficients of one basis function `N[knots]` on `[a, b]`: `N(u) =
|
||||
/// Σ cᵢ Bᵢ,ₚ((u−a)/(b−a))` there. `[a, b]` must not contain a knot in its interior (the
|
||||
/// function is one polynomial on it). Found by inserting `a` and `b` into the function until
|
||||
/// every surviving piece is a Bernstein polynomial `N[a…a, b…b]` (Sederberg et al. 2004, §3;
|
||||
/// NURBS Book §5.4, decomposition into Bézier segments).
|
||||
pub fn basis_bezier(knots: &[f64], a: f64, b: f64) -> SplineResult<Vec<f64>> {
|
||||
let Some(p) = knots.len().checked_sub(2) else { return invalid("a basis function needs at least two knots") };
|
||||
if p > MAX_SPLINE_DEGREE {
|
||||
return Err(SplineError::Unsupported(format!("degree {p} (at most {MAX_SPLINE_DEGREE})")));
|
||||
}
|
||||
check_knots(knots)?;
|
||||
if !(a.is_finite() && b.is_finite() && a < b) {
|
||||
return invalid("a Bézier interval must be finite and non-empty");
|
||||
}
|
||||
if knots.iter().any(|&x| x > a && x < b) {
|
||||
return invalid("a knot lies inside the Bézier interval");
|
||||
}
|
||||
let mut out = vec![0.0; p + 1];
|
||||
let mut work = vec![(knots.to_vec(), 1.0)];
|
||||
let mut steps = 0usize;
|
||||
while let Some((kn, c)) = work.pop() {
|
||||
steps += 1;
|
||||
if steps > 1 << 12 {
|
||||
return Err(SplineError::TooLarge("Bézier extraction did not converge".into()));
|
||||
}
|
||||
let (Some(&lo), Some(&hi)) = (kn.first(), kn.last()) else { continue };
|
||||
if hi <= a || lo >= b || c == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let insert = if kn.iter().any(|&x| x < a) {
|
||||
Some(a)
|
||||
} else if kn.iter().any(|&x| x > b) {
|
||||
Some(b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match insert {
|
||||
Some(k) => {
|
||||
let (c1, c2) = split_basis(&kn, k).ok_or_else(|| SplineError::Invalid("knot insertion outside the support".into()))?;
|
||||
let [first, second] = split_knots(&kn, k);
|
||||
work.push((first, c * c1));
|
||||
work.push((second, c * c2));
|
||||
}
|
||||
None => {
|
||||
// Knots a×(p+1−i), b×(i+1): the Bernstein polynomial Bᵢ,ₚ.
|
||||
let nb = kn.iter().filter(|&&x| x == b).count();
|
||||
if let Some(slot) = nb.checked_sub(1).and_then(|i| out.get_mut(i)) {
|
||||
*slot += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The span index `k` with `knots[k] ≤ u < knots[k+1]` among the valid spans `p ≤ k < n`
|
||||
/// (`n` control points), the last span closed at the domain end (NURBS Book A2.1).
|
||||
pub fn find_span(knots: &[f64], p: usize, n: usize, u: f64) -> Option<usize> {
|
||||
if n <= p || knots.len() != n + p + 1 || !u.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let (lo, hi) = (*knots.get(p)?, *knots.get(n)?);
|
||||
if u < lo || u > hi {
|
||||
return None;
|
||||
}
|
||||
// The last non-empty span in [p, n−1] that starts at or before u.
|
||||
let mut k = p;
|
||||
for i in p..n {
|
||||
if let (Some(&a), Some(&b)) = (knots.get(i), knots.get(i + 1))
|
||||
&& a <= u
|
||||
&& a < b
|
||||
{
|
||||
k = i;
|
||||
}
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
|
||||
/// Insert the knot `u` once into a B-spline with degree `p`, knots and homogeneous control
|
||||
/// points (Boehm; NURBS Book eq. 5.10 / A5.1). `u` must lie in the domain `[knots[p],
|
||||
/// knots[n]]`; the curve is unchanged.
|
||||
pub fn insert_knot(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> SplineResult<(Vec<f64>, Vec<HPoint>)> {
|
||||
let n = ctrl.len();
|
||||
if n <= p || knots.len() != n + p + 1 {
|
||||
return invalid("knot and control point counts do not match the degree");
|
||||
}
|
||||
if knots.len() >= MAX_KNOTS {
|
||||
return Err(SplineError::TooLarge("too many knots".into()));
|
||||
}
|
||||
check_knots(knots)?;
|
||||
let k = find_span(knots, p, n, u).ok_or_else(|| SplineError::Invalid(format!("knot {u} is outside the domain")))?;
|
||||
let kn = |i: usize| knots.get(i).copied().unwrap_or(0.0);
|
||||
let pt = |i: usize| ctrl.get(i).copied().unwrap_or(HPoint::ZERO);
|
||||
let mut q = Vec::with_capacity(n + 1);
|
||||
for i in 0..=n {
|
||||
q.push(if i + p <= k {
|
||||
pt(i)
|
||||
} else if i > k {
|
||||
pt(i - 1)
|
||||
} else {
|
||||
let den = kn(i + p) - kn(i);
|
||||
let alpha = if den > 0.0 { (u - kn(i)) / den } else { 1.0 };
|
||||
pt(i) * alpha + pt(i - 1) * (1.0 - alpha)
|
||||
});
|
||||
}
|
||||
let mut nk = knots.to_vec();
|
||||
nk.insert(k + 1, u);
|
||||
Ok((nk, q))
|
||||
}
|
||||
|
||||
/// Bernstein polynomial `Bᵢ,ₚ(x)`.
|
||||
pub fn bernstein(p: usize, i: usize, x: f64) -> f64 {
|
||||
if i > p {
|
||||
return 0.0;
|
||||
}
|
||||
let mut c = 1.0;
|
||||
for m in 0..i {
|
||||
c = c * (p - m) as f64 / (m + 1) as f64;
|
||||
}
|
||||
c * x.powi(i as i32) * (1.0 - x).powi((p - i) as i32)
|
||||
}
|
||||
|
||||
/// A Bézier curve's point at `x ∈ [0, 1]` (de Casteljau, NURBS Book §1.4).
|
||||
pub fn bezier_point(ctrl: &[HPoint], x: f64) -> HPoint {
|
||||
subdivide(ctrl, x).0.last().copied().unwrap_or(HPoint::ZERO)
|
||||
}
|
||||
|
||||
/// The control points of the piece of a Bézier curve over `[a, b] ⊂ [0, 1]` (two de Casteljau
|
||||
/// subdivisions).
|
||||
pub fn bezier_segment(ctrl: &[HPoint], a: f64, b: f64) -> SplineResult<Vec<HPoint>> {
|
||||
if !(a.is_finite() && b.is_finite() && 0.0 <= a && a < b && b <= 1.0) {
|
||||
return invalid("a Bézier segment must satisfy 0 ≤ a < b ≤ 1");
|
||||
}
|
||||
// Left part [0, b], then its right part from a/b.
|
||||
let left = subdivide(ctrl, b).0;
|
||||
Ok(subdivide(&left, a / b).1)
|
||||
}
|
||||
|
||||
/// Split a Bézier curve at `x` (de Casteljau): control points of `[0, x]` and `[x, 1]`.
|
||||
fn subdivide(ctrl: &[HPoint], x: f64) -> (Vec<HPoint>, Vec<HPoint>) {
|
||||
let mut left = Vec::with_capacity(ctrl.len());
|
||||
let mut right = Vec::with_capacity(ctrl.len());
|
||||
let mut pts = ctrl.to_vec();
|
||||
while let (Some(&f), Some(&l)) = (pts.first(), pts.last()) {
|
||||
left.push(f);
|
||||
right.push(l);
|
||||
pts = pts.windows(2).map(|w| if let [a, b] = w { *a * (1.0 - x) + *b * x } else { HPoint::ZERO }).collect();
|
||||
}
|
||||
right.reverse();
|
||||
(left, right)
|
||||
}
|
||||
|
||||
/// A rational tensor Bézier patch over the parameter rectangle `s × t`: control point `(i, j)`
|
||||
/// (i along s, j along t) at `ctrl[j·(degree[0]+1) + i]`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BezierPatch {
|
||||
pub s: [f64; 2],
|
||||
pub t: [f64; 2],
|
||||
pub degree: [usize; 2],
|
||||
pub ctrl: Vec<HPoint>,
|
||||
}
|
||||
|
||||
impl BezierPatch {
|
||||
/// Control point `(i, j)`.
|
||||
pub fn point(&self, i: usize, j: usize) -> HPoint {
|
||||
self.ctrl.get(j * (self.degree[0] + 1) + i).copied().unwrap_or(HPoint::ZERO)
|
||||
}
|
||||
/// Homogeneous point at global parameters `(s, t)`.
|
||||
pub fn eval_h(&self, s: f64, t: f64) -> HPoint {
|
||||
let x = (s - self.s[0]) / (self.s[1] - self.s[0]);
|
||||
let y = (t - self.t[0]) / (self.t[1] - self.t[0]);
|
||||
let mut acc = HPoint::ZERO;
|
||||
for j in 0..=self.degree[1] {
|
||||
let bj = bernstein(self.degree[1], j, y);
|
||||
for i in 0..=self.degree[0] {
|
||||
acc += self.point(i, j) * (bernstein(self.degree[0], i, x) * bj);
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
/// Point at global parameters `(s, t)`.
|
||||
pub fn eval(&self, s: f64, t: f64) -> Option<Vec3> {
|
||||
self.eval_h(s, t).project()
|
||||
}
|
||||
/// The control points of one side as a Bézier curve: `0` t = t₀ (s rising), `1` s = s₁
|
||||
/// (t rising), `2` t = t₁ (s rising), `3` s = s₀ (t rising).
|
||||
pub fn side(&self, side: usize) -> Vec<HPoint> {
|
||||
let [ps, pt] = self.degree;
|
||||
match side {
|
||||
0 => (0..=ps).map(|i| self.point(i, 0)).collect(),
|
||||
1 => (0..=pt).map(|j| self.point(ps, j)).collect(),
|
||||
2 => (0..=ps).map(|i| self.point(i, pt)).collect(),
|
||||
_ => (0..=pt).map(|j| self.point(0, j)).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rational tensor-product B-spline (NURBS) surface. Control point `(i, j)` (i along u, j
|
||||
/// along v) is `ctrl[j·count_u + i]`; the domain is `[knots_u[p], knots_u[n]] × …`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct NurbsSurface {
|
||||
degree: [usize; 2],
|
||||
knots: [Vec<f64>; 2],
|
||||
count: [usize; 2],
|
||||
ctrl: Vec<HPoint>,
|
||||
}
|
||||
|
||||
impl NurbsSurface {
|
||||
/// A surface from Cartesian points and positive weights (one per point, u fastest).
|
||||
pub fn new(degree: [usize; 2], knots: [Vec<f64>; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<NurbsSurface> {
|
||||
Self::from_homogeneous(degree, knots, weighted_points(points, weights)?)
|
||||
}
|
||||
|
||||
/// A surface from homogeneous control points (u fastest).
|
||||
pub fn from_homogeneous(degree: [usize; 2], knots: [Vec<f64>; 2], ctrl: Vec<HPoint>) -> SplineResult<NurbsSurface> {
|
||||
let mut count = [0; 2];
|
||||
for d in 0..2 {
|
||||
let (p, kn) = (degree[d], &knots[d]);
|
||||
if p == 0 || p > MAX_SPLINE_DEGREE {
|
||||
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
|
||||
}
|
||||
check_knots(kn)?;
|
||||
let n = kn.len().checked_sub(p + 1).filter(|&n| n > p).ok_or_else(|| SplineError::Invalid("too few knots for the degree".into()))?;
|
||||
if !(kn.get(p) < kn.get(n)) {
|
||||
return invalid("the surface's domain is empty");
|
||||
}
|
||||
count[d] = n;
|
||||
}
|
||||
if count[0].checked_mul(count[1]).is_none_or(|c| c > MAX_SURFACE_POINTS) {
|
||||
return Err(SplineError::TooLarge(format!("more than {MAX_SURFACE_POINTS} control points")));
|
||||
}
|
||||
if ctrl.len() != count[0] * count[1] {
|
||||
return invalid(format!("{} control points, expected {}", ctrl.len(), count[0] * count[1]));
|
||||
}
|
||||
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
|
||||
return invalid("control points must be finite with positive weights");
|
||||
}
|
||||
Ok(NurbsSurface { degree, knots, count, ctrl })
|
||||
}
|
||||
|
||||
pub fn degree(&self) -> [usize; 2] {
|
||||
self.degree
|
||||
}
|
||||
pub fn knots(&self) -> &[Vec<f64>; 2] {
|
||||
&self.knots
|
||||
}
|
||||
/// Control point counts along u and v.
|
||||
pub fn count(&self) -> [usize; 2] {
|
||||
self.count
|
||||
}
|
||||
pub fn homogeneous(&self) -> &[HPoint] {
|
||||
&self.ctrl
|
||||
}
|
||||
/// Control point `(i, j)`.
|
||||
pub fn point(&self, i: usize, j: usize) -> HPoint {
|
||||
self.ctrl.get(j * self.count[0] + i).copied().unwrap_or(HPoint::ZERO)
|
||||
}
|
||||
|
||||
/// The parameter domain `[[u₀, u₁], [v₀, v₁]]`.
|
||||
pub fn domain(&self) -> [[f64; 2]; 2] {
|
||||
let d = |k: usize| {
|
||||
let (p, n, kn) = (self.degree[k], self.count[k], &self.knots[k]);
|
||||
[kn.get(p).copied().unwrap_or(0.0), kn.get(n).copied().unwrap_or(0.0)]
|
||||
};
|
||||
[d(0), d(1)]
|
||||
}
|
||||
|
||||
/// The point at `(u, v)`, which must lie in the domain.
|
||||
pub fn eval(&self, u: f64, v: f64) -> SplineResult<Vec3> {
|
||||
let dom = self.domain();
|
||||
let mut spans = [0; 2];
|
||||
let mut basis: [Vec<f64>; 2] = [Vec::new(), Vec::new()];
|
||||
for (d, x) in [u, v].into_iter().enumerate() {
|
||||
let (p, n, kn) = (self.degree[d], self.count[d], &self.knots[d]);
|
||||
let span = find_span(kn, p, n, x).ok_or_else(|| SplineError::Invalid(format!("({u}, {v}) is outside the domain")))?;
|
||||
let side = if x >= dom[d][1] { Side::Left } else { Side::Right };
|
||||
spans[d] = span;
|
||||
basis[d] = (span - p..=span).map(|i| kn.get(i..i + p + 2).map_or(0.0, |k| local_basis(k, x, side, 0)[0])).collect();
|
||||
}
|
||||
let mut acc = HPoint::ZERO;
|
||||
for (b, nj) in basis[1].iter().enumerate() {
|
||||
for (a, ni) in basis[0].iter().enumerate() {
|
||||
acc += self.point(spans[0] - self.degree[0] + a, spans[1] - self.degree[1] + b) * (ni * nj);
|
||||
}
|
||||
}
|
||||
acc.project().ok_or_else(|| SplineError::Invalid("the surface's weights vanish there".into()))
|
||||
}
|
||||
|
||||
/// The same surface with the knot `x` inserted once in direction `dir` ([`Dir::S`] = u).
|
||||
pub fn insert_knot(&self, dir: Dir, x: f64) -> SplineResult<NurbsSurface> {
|
||||
let d = dir.pick(0, 1);
|
||||
let (p, kn) = (self.degree[d], &self.knots[d]);
|
||||
let [nu, nv] = self.count;
|
||||
let (lines, len) = if d == 0 { (nv, nu) } else { (nu, nv) };
|
||||
let at = |line: usize, k: usize| if d == 0 { self.point(k, line) } else { self.point(line, k) };
|
||||
let mut new_knots = Vec::new();
|
||||
let mut rows: Vec<Vec<HPoint>> = Vec::with_capacity(lines);
|
||||
for line in 0..lines {
|
||||
let row: Vec<HPoint> = (0..len).map(|k| at(line, k)).collect();
|
||||
let (nk, nr) = insert_knot(p, kn, &row, x)?;
|
||||
new_knots = nk;
|
||||
rows.push(nr);
|
||||
}
|
||||
let mut knots = self.knots.clone();
|
||||
knots[d] = new_knots;
|
||||
let new_len = len + 1;
|
||||
let mut ctrl = Vec::with_capacity(lines * new_len);
|
||||
if d == 0 {
|
||||
for row in &rows {
|
||||
ctrl.extend_from_slice(row);
|
||||
}
|
||||
} else {
|
||||
for j in 0..new_len {
|
||||
for row in &rows {
|
||||
ctrl.push(row.get(j).copied().unwrap_or(HPoint::ZERO));
|
||||
}
|
||||
}
|
||||
}
|
||||
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
|
||||
}
|
||||
|
||||
/// The same surface on the same domain with clamped knot vectors (`p+1` equal knots at
|
||||
/// each end, so the boundary curves are given by the boundary rows of control points):
|
||||
/// insert each domain end until it has multiplicity `p`, then drop the basis functions
|
||||
/// that vanish on the domain (NURBS Book §5.2 knot insertion; a basis function's last span
|
||||
/// does not depend on its first knot, so that knot can be moved to the domain end).
|
||||
pub fn clamped(&self) -> SplineResult<NurbsSurface> {
|
||||
let mut s = self.clone();
|
||||
for dir in [Dir::S, Dir::T] {
|
||||
let d = dir.pick(0, 1);
|
||||
let p = s.degree[d];
|
||||
for end in 0..2 {
|
||||
let a = s.domain()[d][end];
|
||||
while s.knots[d].iter().filter(|&&x| x == a).count() < p {
|
||||
s = s.insert_knot(dir, a)?;
|
||||
}
|
||||
s = s.trim_end(d, end, a)?;
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Drop the basis functions that vanish on the domain past the knot `a` (multiplicity ≥ p)
|
||||
/// at the start (`end == 0`) or the end of direction `d`.
|
||||
fn trim_end(&self, d: usize, end: usize, a: f64) -> SplineResult<NurbsSurface> {
|
||||
let p = self.degree[d];
|
||||
let kn = &self.knots[d];
|
||||
let n = self.count[d];
|
||||
let (first, last) = match end {
|
||||
0 => {
|
||||
let last_a = kn.iter().rposition(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
|
||||
// Functions i with i + p + 1 ≤ last_a vanish on [a, …).
|
||||
(last_a.saturating_sub(p), n)
|
||||
}
|
||||
_ => {
|
||||
let first_a = kn.iter().position(|&x| x == a).ok_or_else(|| SplineError::Invalid("clamp".into()))?;
|
||||
// Functions i with i ≥ first_a vanish on (…, a].
|
||||
(0, first_a.min(n))
|
||||
}
|
||||
};
|
||||
if last <= first + p {
|
||||
return invalid("clamp: too few control points remain");
|
||||
}
|
||||
let mut knots = self.knots.clone();
|
||||
let mut nk: Vec<f64> = kn.get(first..last + p + 1).map(<[f64]>::to_vec).unwrap_or_default();
|
||||
match end {
|
||||
0 => nk.first_mut().into_iter().for_each(|x| *x = a),
|
||||
_ => nk.last_mut().into_iter().for_each(|x| *x = a),
|
||||
}
|
||||
knots[d] = nk;
|
||||
let [nu, nv] = self.count;
|
||||
let mut ctrl = Vec::new();
|
||||
for j in 0..nv {
|
||||
for i in 0..nu {
|
||||
let k = if d == 0 { i } else { j };
|
||||
if k >= first && k < last {
|
||||
ctrl.push(self.point(i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
NurbsSurface::from_homogeneous(self.degree, knots, ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A small deterministic generator for test data.
|
||||
pub(crate) struct Lcg(pub(crate) u64);
|
||||
impl Lcg {
|
||||
pub(crate) fn unit(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
}
|
||||
|
||||
const KNOTS: [f64; 12] = [0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.5, 0.9, 1.0, 1.0, 1.0, 1.0];
|
||||
|
||||
fn samples() -> Vec<f64> {
|
||||
(0..=200).map(|k| k as f64 / 200.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_partition_of_unity_non_negative_compact_support() {
|
||||
let p = 3;
|
||||
let n = KNOTS.len() - p - 1;
|
||||
for u in samples() {
|
||||
let side = if u >= 1.0 { Side::Left } else { Side::Right };
|
||||
let mut sum = 0.0;
|
||||
for i in 0..n {
|
||||
let local = &KNOTS[i..i + p + 2];
|
||||
let b = local_basis(local, u, side, 0)[0];
|
||||
assert!(b >= -1e-15, "N{i}({u}) = {b}");
|
||||
if u < local[0] || u > local[p + 1] {
|
||||
assert_eq!(b, 0.0, "N{i} is not zero outside its support at {u}");
|
||||
}
|
||||
sum += b;
|
||||
}
|
||||
assert!((sum - 1.0).abs() < 1e-14, "Σ N({u}) = {sum}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_derivatives_match_finite_differences() {
|
||||
let p = 3;
|
||||
let h = 1e-6;
|
||||
let mut worst = 0.0f64;
|
||||
for i in 0..KNOTS.len() - p - 1 {
|
||||
let local = &KNOTS[i..i + p + 2];
|
||||
for u in samples() {
|
||||
if KNOTS.iter().any(|k| (k - u).abs() < 2.0 * h) {
|
||||
continue;
|
||||
}
|
||||
let d = local_basis(local, u, Side::Right, 2);
|
||||
let f = |x: f64| local_basis(local, x, Side::Right, 0)[0];
|
||||
let fd1 = (f(u + h) - f(u - h)) / (2.0 * h);
|
||||
let df = |x: f64| local_basis(local, x, Side::Right, 1)[1];
|
||||
let fd2 = (df(u + h) - df(u - h)) / (2.0 * h);
|
||||
worst = worst.max((d[1] - fd1).abs()).max((d[2] - fd2).abs() / 100.0);
|
||||
}
|
||||
}
|
||||
assert!(worst < 1e-5, "derivative error {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_function_knot_insertion_is_exact() {
|
||||
let mut rng = Lcg(7);
|
||||
for _ in 0..200 {
|
||||
let mut kn: Vec<f64> = (0..5).map(|_| (rng.unit() * 8.0).round() / 8.0).collect();
|
||||
kn.sort_by(f64::total_cmp);
|
||||
if kn[0] == kn[4] {
|
||||
continue;
|
||||
}
|
||||
let k = kn[0] + (kn[4] - kn[0]) * (rng.unit() * 8.0).round() / 8.0;
|
||||
let (c1, c2) = split_basis(&kn, k).unwrap();
|
||||
let mut merged = kn.clone();
|
||||
let at = merged.iter().position(|&x| x > k).unwrap_or(5);
|
||||
merged.insert(at, k);
|
||||
for u in samples() {
|
||||
let u = kn[0] - 0.1 + u * (kn[4] - kn[0] + 0.2);
|
||||
let f = |k: &[f64]| local_basis(k, u, Side::Right, 0)[0];
|
||||
let lhs = f(&kn);
|
||||
let rhs = c1 * f(&merged[..5]) + c2 * f(&merged[1..]);
|
||||
assert!((lhs - rhs).abs() < 1e-14, "{kn:?} insert {k} at {u}: {lhs} vs {rhs}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_bezier_coefficients_reproduce_the_function() {
|
||||
let p = 3;
|
||||
let spans: Vec<(f64, f64)> = KNOTS.windows(2).filter(|w| w[0] < w[1]).map(|w| (w[0], w[1])).collect();
|
||||
for i in 0..KNOTS.len() - p - 1 {
|
||||
let local = &KNOTS[i..i + p + 2];
|
||||
for &(a, b) in &spans {
|
||||
let c = basis_bezier(local, a, b).unwrap();
|
||||
for k in 0..=20 {
|
||||
let x = k as f64 / 20.0;
|
||||
let u = a + (b - a) * x;
|
||||
let side = if x >= 1.0 { Side::Left } else { Side::Right };
|
||||
let want = local_basis(local, u, side, 0)[0];
|
||||
let got: f64 = c.iter().enumerate().map(|(j, cj)| cj * bernstein(p, j, x)).sum();
|
||||
assert!((want - got).abs() < 1e-14, "N{i} on [{a}, {b}] at {u}: {want} vs {got}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(basis_bezier(&KNOTS[..5], 0.0, 0.5).is_err(), "a knot inside the interval is refused");
|
||||
assert!(basis_bezier(&KNOTS[..5], f64::NAN, 0.5).is_err());
|
||||
}
|
||||
|
||||
fn curve(p: usize, knots: &[f64], ctrl: &[HPoint], u: f64) -> Vec3 {
|
||||
let n = ctrl.len();
|
||||
let side = if u >= knots[n] { Side::Left } else { Side::Right };
|
||||
let mut acc = HPoint::ZERO;
|
||||
for (i, c) in ctrl.iter().enumerate() {
|
||||
acc += *c * local_basis(&knots[i..i + p + 2], u, side, 0)[0];
|
||||
}
|
||||
acc.project().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boehm_insertion_keeps_the_curve() {
|
||||
let p = 3;
|
||||
let mut rng = Lcg(3);
|
||||
let ctrl: Vec<HPoint> = (0..8).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0, 0.5 + rng.unit())).collect();
|
||||
let (mut kn, mut pts) = (KNOTS.to_vec(), ctrl.clone());
|
||||
for u in [0.0, 0.1, 0.3, 0.3, 0.77, 1.0, 1.0] {
|
||||
(kn, pts) = insert_knot(p, &kn, &pts, u).unwrap();
|
||||
}
|
||||
let mut worst = 0.0f64;
|
||||
for u in samples() {
|
||||
worst = worst.max(curve(p, &KNOTS, &ctrl, u).dist(curve(p, &kn, &pts, u)));
|
||||
}
|
||||
assert!(worst < 1e-13, "deviation {worst}");
|
||||
assert!(insert_knot(p, &KNOTS, &ctrl, 1.5).is_err());
|
||||
assert!(insert_knot(p, &KNOTS, &ctrl, f64::NAN).is_err());
|
||||
}
|
||||
|
||||
pub(crate) fn random_surface(seed: u64, ku: Vec<f64>, kv: Vec<f64>, degree: [usize; 2]) -> NurbsSurface {
|
||||
let mut rng = Lcg(seed);
|
||||
let n = (ku.len() - degree[0] - 1) * (kv.len() - degree[1] - 1);
|
||||
let pts: Vec<Vec3> = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
|
||||
let w: Vec<f64> = (0..n).map(|_| 0.5 + rng.unit()).collect();
|
||||
NurbsSurface::new(degree, [ku, kv], &pts, &w).unwrap()
|
||||
}
|
||||
|
||||
fn max_deviation(a: &NurbsSurface, b: &NurbsSurface) -> f64 {
|
||||
let [du, dv] = a.domain();
|
||||
let mut worst = 0.0f64;
|
||||
for i in 0..=30 {
|
||||
for j in 0..=30 {
|
||||
let u = du[0] + (du[1] - du[0]) * i as f64 / 30.0;
|
||||
let v = dv[0] + (dv[1] - dv[0]) * j as f64 / 30.0;
|
||||
worst = worst.max(a.eval(u, v).unwrap().dist(b.eval(u, v).unwrap()));
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_knot_insertion_and_clamping_keep_the_surface() {
|
||||
let uniform: Vec<f64> = (0..10).map(|k| k as f64).collect();
|
||||
let s = random_surface(11, uniform.clone(), (0..9).map(|k| k as f64 * 0.5).collect(), [3, 3]);
|
||||
let r = s.insert_knot(Dir::S, 4.5).unwrap().insert_knot(Dir::T, 2.25).unwrap();
|
||||
assert!(max_deviation(&s, &r) < 1e-12);
|
||||
let c = s.clamped().unwrap();
|
||||
assert_eq!(c.domain(), s.domain());
|
||||
assert_eq!(&c.knots()[0][..4], &[3.0; 4]);
|
||||
assert_eq!(&c.knots()[1][c.knots()[1].len() - 4..], &[2.5; 4]);
|
||||
let dev = max_deviation(&s, &c);
|
||||
assert!(dev < 1e-12, "clamping moved the surface by {dev}");
|
||||
// A clamped surface interpolates its corner control points.
|
||||
let corner = c.eval(3.0, 1.5).unwrap();
|
||||
assert!(corner.dist(c.point(0, 0).project().unwrap()) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_segments_lie_on_the_curve() {
|
||||
let mut rng = Lcg(5);
|
||||
let ctrl: Vec<HPoint> = (0..4).map(|_| HPoint::weighted(Vec3::new(rng.unit(), rng.unit(), 0.0), 0.5 + rng.unit())).collect();
|
||||
let seg = bezier_segment(&ctrl, 0.2, 0.7).unwrap();
|
||||
for k in 0..=10 {
|
||||
let x = k as f64 / 10.0;
|
||||
let want = bezier_point(&ctrl, 0.2 + 0.5 * x).project().unwrap();
|
||||
assert!(bezier_point(&seg, x).project().unwrap().dist(want) < 1e-14);
|
||||
}
|
||||
assert!(bezier_segment(&ctrl, 0.7, 0.2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_spline_input_is_refused() {
|
||||
let pts = [Vec3::ZERO; 16];
|
||||
let w = [1.0; 16];
|
||||
let k = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
|
||||
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &w).is_ok());
|
||||
assert!(NurbsSurface::new([3, 3], [vec![0.0, f64::NAN, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
|
||||
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts, &[0.0; 16]).is_err());
|
||||
assert!(NurbsSurface::new([3, 3], [k.clone(), k.clone()], &pts[..15], &w[..15]).is_err());
|
||||
assert!(NurbsSurface::new([0, 3], [k.clone(), k.clone()], &pts, &w).is_err());
|
||||
assert!(NurbsSurface::new([9, 3], [k.clone(), k.clone()], &pts, &w).is_err());
|
||||
assert!(NurbsSurface::new([3, 3], [vec![1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0], k.clone()], &pts, &w).is_err());
|
||||
assert!(check_knots(&vec![0.0; MAX_KNOTS + 1]).is_err());
|
||||
let s = NurbsSurface::new([3, 3], [k.clone(), k], &pts, &w).unwrap();
|
||||
assert!(s.eval(2.0, 0.5).is_err());
|
||||
assert!(s.eval(f64::NAN, 0.5).is_err());
|
||||
assert_eq!(local_basis(&[], 0.5, Side::Right, 1), vec![0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and
|
||||
//! triangle meshes with their measures (volume, area, centroid). Units are millimetres.
|
||||
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes,
|
||||
//! triangle meshes with their measures (volume, area, centroid), and freeform surface maths
|
||||
//! (B-spline basis and NURBS surfaces in [`bspline`], T-splines in [`tspline`]). Units are
|
||||
//! millimetres.
|
||||
//!
|
||||
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
|
||||
//! builds on it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bspline;
|
||||
mod mesh;
|
||||
mod plane;
|
||||
mod profile;
|
||||
pub mod tspline;
|
||||
mod vec;
|
||||
|
||||
pub use mesh::{Aabb3, InsideIndex, Mesh, MeshMeasure};
|
||||
|
||||
@@ -527,11 +527,23 @@ impl Region2 {
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
|
||||
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
|
||||
// Sweep upward: an edge joins the slabs once its lower end is passed and leaves after
|
||||
// its upper end, so each slab sorts only the edges crossing it (a finely sampled ring
|
||||
// has hundreds of slabs and edges, but four edges in each slab).
|
||||
edges.sort_by(|a, b| a.0.y.total_cmp(&b.0.y));
|
||||
let mut next = 0;
|
||||
let mut span: Vec<&(Vec2, Vec2)> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for w in ys.windows(2) {
|
||||
let (y0, y1) = (w[0], w[1]);
|
||||
let ym = (y0 + y1) * 0.5;
|
||||
let mut span: Vec<&(Vec2, Vec2)> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
|
||||
while let Some(e) = edges.get(next)
|
||||
&& e.0.y <= ym
|
||||
{
|
||||
span.push(e);
|
||||
next += 1;
|
||||
}
|
||||
span.retain(|e| e.1.y >= ym);
|
||||
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
|
||||
for pair in span.chunks(2) {
|
||||
let [l, r] = pair else { continue };
|
||||
@@ -630,6 +642,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sweep visits only the edges crossing each slab; it must give the triangles that
|
||||
/// testing every edge against every slab gives, for a finely sampled ring too (#164).
|
||||
#[test]
|
||||
fn triangulation_sweep_matches_every_edge_against_every_slab() {
|
||||
fn every_edge(r: &Region2, tol: f64) -> Vec<[Vec2; 3]> {
|
||||
let mut edges = Vec::new();
|
||||
for l in std::iter::once(&r.outer).chain(&r.holes) {
|
||||
let pl = l.polyline(tol);
|
||||
for i in 0..pl.len() {
|
||||
let (a, b) = (pl[i], pl[(i + 1) % pl.len()]);
|
||||
if (a.y - b.y).abs() > 1e-12 {
|
||||
edges.push(if a.y < b.y { (a, b) } else { (b, a) });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ys: Vec<f64> = edges.iter().flat_map(|(a, b): &(Vec2, Vec2)| [a.y, b.y]).collect();
|
||||
ys.sort_by(f64::total_cmp);
|
||||
ys.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
|
||||
let x_at = |e: &(Vec2, Vec2), y: f64| e.0.x + (y - e.0.y) / (e.1.y - e.0.y) * (e.1.x - e.0.x);
|
||||
let mut out = Vec::new();
|
||||
for w in ys.windows(2) {
|
||||
let ym = (w[0] + w[1]) * 0.5;
|
||||
let mut span: Vec<_> = edges.iter().filter(|e| e.0.y <= ym && e.1.y >= ym).collect();
|
||||
span.sort_by(|a, b| x_at(a, ym).total_cmp(&x_at(b, ym)));
|
||||
for pair in span.chunks(2) {
|
||||
let [l, r] = pair else { continue };
|
||||
let (a, b) = (Vec2::new(x_at(l, w[0]), w[0]), Vec2::new(x_at(r, w[0]), w[0]));
|
||||
let (c, d) = (Vec2::new(x_at(r, w[1]), w[1]), Vec2::new(x_at(l, w[1]), w[1]));
|
||||
out.extend([[a, b, c], [a, c, d]]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let comb = Loop2::polygon(&[
|
||||
Vec2::new(0.0, 0.0),
|
||||
Vec2::new(9.0, 0.0),
|
||||
Vec2::new(9.0, 6.0),
|
||||
Vec2::new(7.0, 2.0),
|
||||
Vec2::new(5.0, 7.0),
|
||||
Vec2::new(3.0, 2.5),
|
||||
Vec2::new(1.0, 6.0),
|
||||
]);
|
||||
let ring = Region2 { outer: Loop2::circle(Vec2::ZERO, 45.0), holes: vec![Loop2::circle(Vec2::ZERO, 40.0).reversed()] };
|
||||
let regions = [Region2 { outer: comb, holes: vec![Loop2::circle(Vec2::new(4.5, 1.2), 0.6).reversed()] }, ring];
|
||||
for (r, tol) in regions.iter().zip([1e-3, 1e-4]) {
|
||||
let tris = r.triangulate(tol);
|
||||
assert!(tris.len() > 100);
|
||||
assert_eq!(tris, every_edge(r, tol));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangulated_area() {
|
||||
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 8.0), Vec2::new(5.0, 3.0), Vec2::new(0.0, 8.0)]);
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Conversions: a tensor NURBS surface as a T-spline and back (a T-mesh without T-junctions is
|
||||
//! a tensor B-spline; 2003 §3 and §5), and Bézier extraction — the surface as rational Bézier
|
||||
//! patches over the rectangles where every blending function is one polynomial.
|
||||
|
||||
use super::mesh::{Grid, TMesh};
|
||||
use super::{Blend, TSpline, frame};
|
||||
use crate::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, SplineResult, basis_bezier, invalid};
|
||||
|
||||
/// Most unit-edge marks and coefficient products in one Bézier extraction.
|
||||
const MAX_EXTRACT_WORK: usize = 1 << 26;
|
||||
|
||||
impl TSpline {
|
||||
/// A tensor NURBS surface of odd bi-degree as a T-spline on the full grid: column `i` of the
|
||||
/// mesh is knot `i + (p+1)/2` of the knot vector, so the blending function at a vertex is
|
||||
/// the B-spline basis function of that control point (rays that leave the mesh repeat the
|
||||
/// boundary line, 2003 §3). Knot vectors whose first and last `(p+1)/2 + 1` knots are not
|
||||
/// equal are clamped first (same surface on the same domain).
|
||||
pub fn from_nurbs(n: &NurbsSurface) -> SplineResult<TSpline> {
|
||||
let degree = n.degree();
|
||||
if degree.iter().any(|p| p % 2 == 0) {
|
||||
return Err(SplineError::Unsupported("a NURBS surface of even degree as a T-spline".into()));
|
||||
}
|
||||
let ends_ok = |k: &[f64], p: usize| {
|
||||
let h = p.div_ceil(2);
|
||||
let same = |w: Option<&[f64]>| w.is_some_and(|w| w.windows(2).all(|x| matches!(x, [a, b] if a == b)));
|
||||
same(k.get(..=h)) && same(k.len().checked_sub(h + 1).and_then(|s| k.get(s..)))
|
||||
};
|
||||
let n = if (0..2).all(|d| ends_ok(&n.knots()[d], degree[d])) { n.clone() } else { n.clamped()? };
|
||||
let lines = |d: usize| -> Vec<f64> {
|
||||
let h = degree[d].div_ceil(2);
|
||||
let k = &n.knots()[d];
|
||||
k.get(h..k.len().saturating_sub(h)).map(<[f64]>::to_vec).unwrap_or_default()
|
||||
};
|
||||
let mesh = TMesh::tensor(lines(0), lines(1))?;
|
||||
TSpline::from_homogeneous(mesh, degree, n.homogeneous().to_vec())
|
||||
}
|
||||
|
||||
/// The T-spline as a tensor NURBS surface, when its mesh has no T-junctions.
|
||||
pub fn to_nurbs(&self) -> SplineResult<NurbsSurface> {
|
||||
if !self.mesh.is_regular() {
|
||||
return Err(SplineError::Unsupported("a T-mesh with T-junctions as one tensor surface".into()));
|
||||
}
|
||||
let pad = |k: &[f64], p: usize| -> Vec<f64> {
|
||||
let h = p.div_ceil(2);
|
||||
let (first, last) = (k.first().copied().unwrap_or(0.0), k.last().copied().unwrap_or(0.0));
|
||||
std::iter::repeat_n(first, h).chain(k.iter().copied()).chain(std::iter::repeat_n(last, h)).collect()
|
||||
};
|
||||
let g = &self.mesh.grid;
|
||||
NurbsSurface::from_homogeneous(self.degree, [pad(&g.s, self.degree[0]), pad(&g.t, self.degree[1])], self.ctrl.clone())
|
||||
}
|
||||
|
||||
/// The surface as rational Bézier patches over rectangles of the domain, inside each of
|
||||
/// which every blending function is one polynomial: the knot lines of all blending
|
||||
/// functions (each function's s-knots across its t-support and t-knots across its
|
||||
/// s-support) are marked on the index grid and the domain's cells are gathered into
|
||||
/// rectangles that no marked line crosses. Each function's Bernstein coefficients on a
|
||||
/// rectangle come from knot insertion ([`basis_bezier`]). Patches tile the domain and meet
|
||||
/// at T-points where their corners land on a neighbour's side.
|
||||
pub fn bezier_elements(&self) -> SplineResult<Vec<BezierPatch>> {
|
||||
let g = &self.mesh.grid;
|
||||
let marks = knot_lines(g, &self.blends)?;
|
||||
let [cs, ct] = frame(self.degree);
|
||||
let (ns, nt) = (g.ns(), g.nt());
|
||||
let (ci_end, cj_end) = (ns - 1 - cs, nt - 1 - ct);
|
||||
let mut taken = vec![false; ns * nt];
|
||||
let is_taken = |taken: &[bool], i: usize, j: usize| taken.get(j * ns + i).copied().unwrap_or(true);
|
||||
let mut rects = Vec::new();
|
||||
for cj in ct..cj_end {
|
||||
for ci in cs..ci_end {
|
||||
if is_taken(&taken, ci, cj) {
|
||||
continue;
|
||||
}
|
||||
let mut i1 = ci + 1;
|
||||
while i1 < ci_end && !is_taken(&taken, i1, cj) && !marks.v(i1, cj) {
|
||||
i1 += 1;
|
||||
}
|
||||
let mut j1 = cj + 1;
|
||||
while j1 < cj_end && (ci..i1).all(|x| !is_taken(&taken, x, j1) && !marks.h(x, j1)) && (ci + 1..i1).all(|x| !marks.v(x, j1)) {
|
||||
j1 += 1;
|
||||
}
|
||||
for y in cj..j1 {
|
||||
for x in ci..i1 {
|
||||
if let Some(c) = taken.get_mut(y * ns + x) {
|
||||
*c = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
rects.push((ci, i1, cj, j1));
|
||||
}
|
||||
}
|
||||
let val = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
|
||||
let [ps, pt] = self.degree;
|
||||
let mut work = 0usize;
|
||||
let mut out = Vec::new();
|
||||
for (i0, i1, j0, j1) in rects {
|
||||
let (s0, s1, t0, t1) = (val(&g.s, i0), val(&g.s, i1), val(&g.t, j0), val(&g.t, j1));
|
||||
if !(s0 < s1 && t0 < t1) {
|
||||
continue;
|
||||
}
|
||||
let mut ctrl = vec![HPoint::ZERO; (ps + 1) * (pt + 1)];
|
||||
for (b, c) in self.blends.iter().zip(&self.ctrl) {
|
||||
let [[bs0, bs1], [bt0, bt1]] = b.support();
|
||||
if !(s1.min(bs1) > s0.max(bs0) && t1.min(bt1) > t0.max(bt0)) {
|
||||
continue;
|
||||
}
|
||||
work += ctrl.len();
|
||||
if work > MAX_EXTRACT_WORK {
|
||||
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
|
||||
}
|
||||
let cs = basis_bezier(&b.sv, s0, s1)?;
|
||||
let ct = basis_bezier(&b.tv, t0, t1)?;
|
||||
for (j, bj) in ct.iter().enumerate() {
|
||||
for (i, bi) in cs.iter().enumerate() {
|
||||
if let Some(slot) = ctrl.get_mut(j * (ps + 1) + i) {
|
||||
*slot += *c * (bi * bj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0)) {
|
||||
return invalid("a Bézier patch has a non-positive weight");
|
||||
}
|
||||
out.push(BezierPatch { s: [s0, s1], t: [t0, t1], degree: self.degree, ctrl });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit edges of the index grid that carry a knot line of some blending function: each
|
||||
/// function's s-knots across its t-support and its t-knots across its s-support.
|
||||
fn knot_lines(g: &Grid, blends: &[Blend]) -> SplineResult<Grid> {
|
||||
let mut m = Grid::empty(g.s.clone(), g.t.clone())?;
|
||||
let mut work = 0usize;
|
||||
for b in blends {
|
||||
let (Some(&s0), Some(&s1), Some(&t0), Some(&t1)) = (b.s.first(), b.s.last(), b.t.first(), b.t.last()) else { continue };
|
||||
work += (b.s.len() * (t1 - t0)) + (b.t.len() * (s1 - s0));
|
||||
if work > MAX_EXTRACT_WORK {
|
||||
return Err(SplineError::TooLarge("Bézier extraction needs too much work".into()));
|
||||
}
|
||||
for &x in &b.s {
|
||||
(t0..t1).for_each(|y| m.set_v(x, y));
|
||||
}
|
||||
for &y in &b.t {
|
||||
(s0..s1).for_each(|x| m.set_h(x, y));
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! The T-mesh in index space (Sederberg, Zheng, Bakenov, Nasri 2003, §3; Scott, Li,
|
||||
//! Sederberg, Hughes 2012, §2): a rectangle of index lines — columns `0..ns` carrying the
|
||||
//! non-decreasing knot values `s[i]`, rows `0..nt` carrying `t[j]` — and the unit edges of
|
||||
//! that grid that are present. Equal knot values on neighbouring lines give zero-length knot
|
||||
//! intervals (repeated knots), which is how clamped boundaries and multiple knots are written.
|
||||
//!
|
||||
//! A valid T-mesh has its outer rectangle, rectangular faces (every grid point is empty, an
|
||||
//! edge passing straight through, a T-junction, a crossing, or one of the four outer
|
||||
//! corners), and at least one edge on every index line. A *vertex* is a point on both a column
|
||||
//! edge and a row edge; every vertex carries a control point.
|
||||
|
||||
use crate::bspline::{Dir, SplineError, SplineResult, check_knots, invalid};
|
||||
|
||||
/// Most index lines in either direction.
|
||||
pub const MAX_INDEX_LINES: usize = 1 << 12;
|
||||
/// Most grid points (`ns · nt`).
|
||||
pub const MAX_GRID_POINTS: usize = 1 << 20;
|
||||
|
||||
/// A run of edges on one index line, given to [`TMesh::from_segments`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Segment {
|
||||
/// Edges on column `i` (constant s) from row `j0` to row `j1`.
|
||||
Column { i: usize, j0: usize, j1: usize },
|
||||
/// Edges on row `j` (constant t) from column `i0` to column `i1`.
|
||||
Row { j: usize, i0: usize, i1: usize },
|
||||
}
|
||||
|
||||
/// A rectangle in index space, `[i0, i1] × [j0, j1]`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct IndexRect {
|
||||
pub i0: usize,
|
||||
pub i1: usize,
|
||||
pub j0: usize,
|
||||
pub j1: usize,
|
||||
}
|
||||
|
||||
/// The index lines and the unit edges present on them; no validity implied.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Grid {
|
||||
pub(crate) s: Vec<f64>,
|
||||
pub(crate) t: Vec<f64>,
|
||||
/// Row edge `(i, j)–(i+1, j)` at `j·(ns−1) + i`.
|
||||
h: Vec<bool>,
|
||||
/// Column edge `(i, j)–(i, j+1)` at `i·(nt−1) + j`.
|
||||
v: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// An empty grid (no edges) after checking sizes and knots.
|
||||
pub(crate) fn empty(s: Vec<f64>, t: Vec<f64>) -> SplineResult<Grid> {
|
||||
for k in [&s, &t] {
|
||||
if k.len() < 2 {
|
||||
return invalid("a T-mesh needs at least two index lines each way");
|
||||
}
|
||||
if k.len() > MAX_INDEX_LINES {
|
||||
return Err(SplineError::TooLarge(format!("{} index lines (at most {MAX_INDEX_LINES})", k.len())));
|
||||
}
|
||||
check_knots(k)?;
|
||||
}
|
||||
if s.len() * t.len() > MAX_GRID_POINTS {
|
||||
return Err(SplineError::TooLarge(format!("{} grid points (at most {MAX_GRID_POINTS})", s.len() * t.len())));
|
||||
}
|
||||
let (ns, nt) = (s.len(), t.len());
|
||||
Ok(Grid { h: vec![false; (ns - 1) * nt], v: vec![false; ns * (nt - 1)], s, t })
|
||||
}
|
||||
|
||||
pub(crate) fn ns(&self) -> usize {
|
||||
self.s.len()
|
||||
}
|
||||
pub(crate) fn nt(&self) -> usize {
|
||||
self.t.len()
|
||||
}
|
||||
|
||||
/// Row edge from `(i, j)` to `(i+1, j)`.
|
||||
pub(crate) fn h(&self, i: usize, j: usize) -> bool {
|
||||
i + 1 < self.ns() && self.h.get(j * (self.ns() - 1) + i).copied().unwrap_or(false)
|
||||
}
|
||||
/// Column edge from `(i, j)` to `(i, j+1)`.
|
||||
pub(crate) fn v(&self, i: usize, j: usize) -> bool {
|
||||
j + 1 < self.nt() && self.v.get(i * (self.nt() - 1) + j).copied().unwrap_or(false)
|
||||
}
|
||||
pub(crate) fn set_h(&mut self, i: usize, j: usize) {
|
||||
let ns = self.ns();
|
||||
if i + 1 < ns
|
||||
&& let Some(e) = self.h.get_mut(j * (ns - 1) + i)
|
||||
{
|
||||
*e = true;
|
||||
}
|
||||
}
|
||||
pub(crate) fn set_v(&mut self, i: usize, j: usize) {
|
||||
let nt = self.nt();
|
||||
if j + 1 < nt
|
||||
&& let Some(e) = self.v.get_mut(i * (nt - 1) + j)
|
||||
{
|
||||
*e = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The point `(i, j)` lies on a column edge (passing through or ending there).
|
||||
pub(crate) fn on_column(&self, i: usize, j: usize) -> bool {
|
||||
(j > 0 && self.v(i, j - 1)) || self.v(i, j)
|
||||
}
|
||||
/// The point `(i, j)` lies on a row edge.
|
||||
pub(crate) fn on_row(&self, i: usize, j: usize) -> bool {
|
||||
(i > 0 && self.h(i - 1, j)) || self.h(i, j)
|
||||
}
|
||||
|
||||
/// The first `count` index lines a ray from `(i, j)` crosses (2003 §3: the knots of a
|
||||
/// blending function are read off rays cast from its anchor). Along s it crosses columns
|
||||
/// carrying an edge at row `j`; along t, rows carrying an edge at column `i`. Nearest
|
||||
/// first; a ray that leaves the mesh repeats the boundary line (clamped ends).
|
||||
pub(crate) fn ray(&self, i: usize, j: usize, dir: Dir, forward: bool, count: usize) -> Vec<usize> {
|
||||
let (start, last) = dir.pick((i, self.ns() - 1), (j, self.nt() - 1));
|
||||
let hit = |x: usize| match dir {
|
||||
Dir::S => self.on_column(x, j),
|
||||
Dir::T => self.on_row(i, x),
|
||||
};
|
||||
let mut out = Vec::with_capacity(count);
|
||||
let mut x = start;
|
||||
while out.len() < count {
|
||||
let next = if forward { (x < last).then(|| x + 1) } else { x.checked_sub(1) };
|
||||
match next {
|
||||
Some(n) => {
|
||||
x = n;
|
||||
if hit(x) {
|
||||
out.push(x);
|
||||
}
|
||||
}
|
||||
None => out.push(x),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The local knot vector (as line indices) of a degree-`p` blending function anchored at
|
||||
/// `(i, j)` in direction `dir`: `(p+1)/2` crossings each side of the anchor's own line
|
||||
/// (odd degree; 2003 §3).
|
||||
pub(crate) fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
|
||||
let h = p.div_ceil(2);
|
||||
let own = dir.pick(i, j);
|
||||
let mut k: Vec<usize> = self.ray(i, j, dir, false, h);
|
||||
k.reverse();
|
||||
k.push(own);
|
||||
k.extend(self.ray(i, j, dir, true, h));
|
||||
k
|
||||
}
|
||||
|
||||
/// The face containing the unit cell whose lower-left corner is `(ci, cj)`.
|
||||
pub(crate) fn face_of_cell(&self, ci: usize, cj: usize) -> IndexRect {
|
||||
let i0 = (0..=ci).rev().find(|&x| self.v(x, cj)).unwrap_or(0);
|
||||
let i1 = (ci + 1..self.ns()).find(|&x| self.v(x, cj)).unwrap_or(self.ns() - 1);
|
||||
let j0 = (0..=cj).rev().find(|&y| self.h(ci, y)).unwrap_or(0);
|
||||
let j1 = (cj + 1..self.nt()).find(|&y| self.h(ci, y)).unwrap_or(self.nt() - 1);
|
||||
IndexRect { i0, i1, j0, j1 }
|
||||
}
|
||||
|
||||
/// Split a face with a full-height column edge at `x` so that the point `(x, y)` lies on
|
||||
/// a column edge (the face above the point, or below it on the top row).
|
||||
pub(crate) fn split_column_at(&mut self, x: usize, y: usize) {
|
||||
let cj = if y + 1 < self.nt() { y } else { y.saturating_sub(1) };
|
||||
let f = self.face_of_cell(x.min(self.ns() - 2), cj);
|
||||
for j in f.j0..f.j1 {
|
||||
self.set_v(x, j);
|
||||
}
|
||||
}
|
||||
/// Split a face with a full-width row edge at `y` so that `(x, y)` lies on a row edge (the
|
||||
/// face to the right of the point, or to its left on the last column).
|
||||
pub(crate) fn split_row_at(&mut self, x: usize, y: usize) {
|
||||
let ci = if x + 1 < self.ns() { x } else { x.saturating_sub(1) };
|
||||
let f = self.face_of_cell(ci, y.min(self.nt() - 2));
|
||||
for i in f.i0..f.i1 {
|
||||
self.set_h(i, y);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a new column at index `at` (`0 < at < ns`) with knot value `value`; row edges
|
||||
/// crossing it are split in two, the new column has no edges.
|
||||
pub(crate) fn insert_column(&mut self, at: usize, value: f64) -> SplineResult<()> {
|
||||
let (ns, nt) = (self.ns(), self.nt());
|
||||
if at == 0 || at >= ns || ns + 1 > MAX_INDEX_LINES || (ns + 1) * nt > MAX_GRID_POINTS {
|
||||
return Err(SplineError::TooLarge("refinement needs too many index lines".into()));
|
||||
}
|
||||
let mut h = Vec::with_capacity(ns * nt);
|
||||
for j in 0..nt {
|
||||
for i in 0..ns {
|
||||
// New row edge i runs (i, i+1) in the new numbering.
|
||||
let old = if i + 1 < at {
|
||||
self.h(i, j)
|
||||
} else if i + 1 == at || i == at {
|
||||
self.h(at - 1, j)
|
||||
} else {
|
||||
self.h(i - 1, j)
|
||||
};
|
||||
h.push(old);
|
||||
}
|
||||
}
|
||||
let mut v = Vec::with_capacity((ns + 1) * (nt - 1));
|
||||
for i in 0..=ns {
|
||||
for j in 0..nt - 1 {
|
||||
v.push(match i.cmp(&at) {
|
||||
std::cmp::Ordering::Less => self.v(i, j),
|
||||
std::cmp::Ordering::Equal => false,
|
||||
std::cmp::Ordering::Greater => self.v(i - 1, j),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.s.insert(at, value);
|
||||
self.h = h;
|
||||
self.v = v;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The grid transposed (s ↔ t), so row operations can reuse column code.
|
||||
pub(crate) fn transposed(&self) -> Grid {
|
||||
let (ns, nt) = (self.ns(), self.nt());
|
||||
let mut g = Grid { s: self.t.clone(), t: self.s.clone(), h: vec![false; (nt - 1) * ns], v: vec![false; nt * (ns - 1)] };
|
||||
for i in 0..ns {
|
||||
for j in 0..nt {
|
||||
if self.h(i, j) {
|
||||
g.set_v(j, i);
|
||||
}
|
||||
if self.v(i, j) {
|
||||
g.set_h(j, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
}
|
||||
|
||||
/// A validated T-mesh.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TMesh {
|
||||
pub(crate) grid: Grid,
|
||||
vertices: Vec<(usize, usize)>,
|
||||
/// Vertex number of grid point `(i, j)` at `j·ns + i`, `u32::MAX` where there is none.
|
||||
vid: Vec<u32>,
|
||||
}
|
||||
|
||||
impl TMesh {
|
||||
/// The full tensor-product mesh on the given knot lines.
|
||||
pub fn tensor(s: Vec<f64>, t: Vec<f64>) -> SplineResult<TMesh> {
|
||||
let (ns, nt) = (s.len(), t.len());
|
||||
let segs: Vec<Segment> = (0..ns)
|
||||
.map(|i| Segment::Column { i, j0: 0, j1: nt.saturating_sub(1) })
|
||||
.chain((0..nt).map(|j| Segment::Row { j, i0: 0, i1: ns.saturating_sub(1) }))
|
||||
.collect();
|
||||
TMesh::from_segments(s, t, &segs)
|
||||
}
|
||||
|
||||
/// A T-mesh from knot lines and runs of edges (the outer rectangle must be among them).
|
||||
pub fn from_segments(s: Vec<f64>, t: Vec<f64>, segments: &[Segment]) -> SplineResult<TMesh> {
|
||||
let mut g = Grid::empty(s, t)?;
|
||||
if segments.len() > MAX_GRID_POINTS {
|
||||
return Err(SplineError::TooLarge("too many segments".into()));
|
||||
}
|
||||
let (ns, nt) = (g.ns(), g.nt());
|
||||
for seg in segments {
|
||||
match *seg {
|
||||
Segment::Column { i, j0, j1 } => {
|
||||
if i >= ns || j0 >= j1 || j1 >= nt {
|
||||
return invalid(format!("column segment {i}: rows {j0}..{j1} are not inside the mesh"));
|
||||
}
|
||||
(j0..j1).for_each(|j| g.set_v(i, j));
|
||||
}
|
||||
Segment::Row { j, i0, i1 } => {
|
||||
if j >= nt || i0 >= i1 || i1 >= ns {
|
||||
return invalid(format!("row segment {j}: columns {i0}..{i1} are not inside the mesh"));
|
||||
}
|
||||
(i0..i1).for_each(|i| g.set_h(i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
TMesh::from_grid(g)
|
||||
}
|
||||
|
||||
/// Validate a grid as a T-mesh.
|
||||
pub(crate) fn from_grid(g: Grid) -> SplineResult<TMesh> {
|
||||
let (ns, nt) = (g.ns(), g.nt());
|
||||
if (0..ns - 1).any(|i| !g.h(i, 0) || !g.h(i, nt - 1)) || (0..nt - 1).any(|j| !g.v(0, j) || !g.v(ns - 1, j)) {
|
||||
return invalid("the T-mesh's outer rectangle is not closed");
|
||||
}
|
||||
if let Some(i) = (0..ns).find(|&i| !(0..nt - 1).any(|j| g.v(i, j))) {
|
||||
return invalid(format!("column {i} carries no edge"));
|
||||
}
|
||||
if let Some(j) = (0..nt).find(|&j| !(0..ns - 1).any(|i| g.h(i, j))) {
|
||||
return invalid(format!("row {j} carries no edge"));
|
||||
}
|
||||
let mut vertices = Vec::new();
|
||||
let mut vid = vec![u32::MAX; ns * nt];
|
||||
for j in 0..nt {
|
||||
for i in 0..ns {
|
||||
let l = i > 0 && g.h(i - 1, j);
|
||||
let r = g.h(i, j);
|
||||
let d = j > 0 && g.v(i, j - 1);
|
||||
let u = g.v(i, j);
|
||||
let n = [l, r, d, u].iter().filter(|&&e| e).count();
|
||||
let corner = (i == 0 || i == ns - 1) && (j == 0 || j == nt - 1);
|
||||
match n {
|
||||
1 => return invalid(format!("an edge ends without meeting another at ({i}, {j})")),
|
||||
2 if (l != r) && !corner => return invalid(format!("a face is not rectangular at ({i}, {j})")),
|
||||
_ => {}
|
||||
}
|
||||
if (l || r) && (d || u) {
|
||||
if let Some(slot) = vid.get_mut(j * ns + i) {
|
||||
*slot = vertices.len() as u32;
|
||||
}
|
||||
vertices.push((i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(TMesh { grid: g, vertices, vid })
|
||||
}
|
||||
|
||||
/// Knot values of the columns.
|
||||
pub fn s_knots(&self) -> &[f64] {
|
||||
&self.grid.s
|
||||
}
|
||||
/// Knot values of the rows.
|
||||
pub fn t_knots(&self) -> &[f64] {
|
||||
&self.grid.t
|
||||
}
|
||||
/// The vertices `(column, row)`, row by row (column fastest). Control points follow this
|
||||
/// order.
|
||||
pub fn vertices(&self) -> &[(usize, usize)] {
|
||||
&self.vertices
|
||||
}
|
||||
/// Vertex number of grid point `(i, j)`.
|
||||
pub fn vertex_at(&self, i: usize, j: usize) -> Option<usize> {
|
||||
if i >= self.grid.ns() {
|
||||
return None;
|
||||
}
|
||||
self.vid.get(j * self.grid.ns() + i).filter(|&&k| k != u32::MAX).map(|&k| k as usize)
|
||||
}
|
||||
/// The parameter point `(s, t)` of vertex `k`.
|
||||
pub fn vertex_param(&self, k: usize) -> Option<(f64, f64)> {
|
||||
let &(i, j) = self.vertices.get(k)?;
|
||||
Some((*self.grid.s.get(i)?, *self.grid.t.get(j)?))
|
||||
}
|
||||
/// Is the edge from `(i, j)` to `(i+1, j)` present?
|
||||
pub fn row_edge(&self, i: usize, j: usize) -> bool {
|
||||
self.grid.h(i, j)
|
||||
}
|
||||
/// Is the edge from `(i, j)` to `(i, j+1)` present?
|
||||
pub fn column_edge(&self, i: usize, j: usize) -> bool {
|
||||
self.grid.v(i, j)
|
||||
}
|
||||
/// Every unit edge present: a tensor-product mesh without T-junctions.
|
||||
pub fn is_regular(&self) -> bool {
|
||||
(0..self.grid.nt()).all(|j| (0..self.grid.ns() - 1).all(|i| self.grid.h(i, j)))
|
||||
&& (0..self.grid.ns()).all(|i| (0..self.grid.nt() - 1).all(|j| self.grid.v(i, j)))
|
||||
}
|
||||
/// The faces, by their lower-left corner.
|
||||
pub fn faces(&self) -> Vec<IndexRect> {
|
||||
let g = &self.grid;
|
||||
let mut out = Vec::new();
|
||||
for cj in 0..g.nt() - 1 {
|
||||
for ci in 0..g.ns() - 1 {
|
||||
if g.v(ci, cj) && g.h(ci, cj) {
|
||||
out.push(g.face_of_cell(ci, cj));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The local knot vector (line indices) of a degree-`p` blending function anchored at
|
||||
/// vertex `(i, j)` in direction `dir` (2003 §3).
|
||||
pub fn local_knots(&self, i: usize, j: usize, dir: Dir, p: usize) -> Vec<usize> {
|
||||
self.grid.local_knots(i, j, dir, p)
|
||||
}
|
||||
|
||||
/// The T-junction extensions for bi-degree `degree` (Scott, Li, Sederberg, Hughes 2012,
|
||||
/// §2, after Li, Zheng, Sederberg, Hughes, Scott 2012): from each interior T-junction a face
|
||||
/// extension runs in the direction of the missing edge across `⌈p/2⌉` perpendicular lines
|
||||
/// carrying an edge there, and an edge extension the other way across `⌊p/2⌋` of them.
|
||||
/// `p` is the degree along the extension.
|
||||
pub fn extensions(&self, degree: [usize; 2]) -> Vec<Extension> {
|
||||
let g = &self.grid;
|
||||
let (ns, nt) = (g.ns(), g.nt());
|
||||
let mut out = Vec::new();
|
||||
for &(i, j) in &self.vertices {
|
||||
if i == 0 || j == 0 || i == ns - 1 || j == nt - 1 {
|
||||
continue;
|
||||
}
|
||||
let (l, r, d, u) = (g.h(i - 1, j), g.h(i, j), g.v(i, j - 1), g.v(i, j));
|
||||
if [l, r, d, u].iter().filter(|&&e| e).count() != 3 {
|
||||
continue;
|
||||
}
|
||||
let (dir, missing_forward) = if !l || !r { (Dir::S, !r) } else { (Dir::T, !u) };
|
||||
let p = dir.pick(degree[0], degree[1]);
|
||||
let reach = |forward: bool, count: usize| -> usize {
|
||||
if count == 0 {
|
||||
return dir.pick(i, j);
|
||||
}
|
||||
g.ray(i, j, dir, forward, count).last().copied().unwrap_or(0)
|
||||
};
|
||||
let face = reach(missing_forward, p.div_ceil(2));
|
||||
let edge = reach(!missing_forward, p / 2);
|
||||
let range = if face < edge { [face, edge] } else { [edge, face] };
|
||||
let at = dir.pick(j, i);
|
||||
out.push(Extension { dir, at, range, from: (i, j) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Analysis-suitable for bi-degree `degree`: no extension along s meets an extension along
|
||||
/// t (Scott et al. 2012, §2). Analysis-suitable T-splines have linearly independent
|
||||
/// blending functions that form a partition of unity.
|
||||
pub fn is_analysis_suitable(&self, degree: [usize; 2]) -> bool {
|
||||
self.conflict(degree).is_none()
|
||||
}
|
||||
|
||||
/// The first extension along s that meets an extension along t.
|
||||
pub(crate) fn conflict(&self, degree: [usize; 2]) -> Option<Extension> {
|
||||
let ns = self.grid.ns();
|
||||
let ext = self.extensions(degree);
|
||||
let mut covered: Vec<Option<usize>> = vec![None; ns * self.grid.nt()];
|
||||
for (k, e) in ext.iter().enumerate().filter(|(_, e)| e.dir == Dir::S) {
|
||||
for x in e.range[0]..=e.range[1] {
|
||||
if let Some(c) = covered.get_mut(e.at * ns + x) {
|
||||
*c = Some(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
ext.iter()
|
||||
.filter(|e| e.dir == Dir::T)
|
||||
.find_map(|e| (e.range[0]..=e.range[1]).find_map(|y| covered.get(y * ns + e.at).copied().flatten()))
|
||||
.and_then(|k| ext.get(k).copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// A T-junction extension: a closed segment along `dir` on index line `at` (the row for
|
||||
/// [`Dir::S`], the column for [`Dir::T`]) spanning the lines `range`, from the T-junction
|
||||
/// `from`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Extension {
|
||||
pub dir: Dir,
|
||||
pub at: usize,
|
||||
pub range: [usize; 2],
|
||||
pub from: (usize, usize),
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! T-spline surfaces: control points on a T-mesh, which allows T-junctions, so a surface can
|
||||
//! be refined locally and patches with different knot vectors join without gaps.
|
||||
//!
|
||||
//! References: Sederberg, Zheng, Bakenov, Nasri 2003, "T-splines and T-NURCCs" (ACM ToG
|
||||
//! 22(3)) — §3 the T-spline equation and the local knot vectors read off the T-mesh by rays;
|
||||
//! Sederberg, Cardon, Finnigan, North, Zheng, Lyche 2004, "T-spline simplification and local
|
||||
//! refinement" (ACM ToG 23(3)) — §3 local refinement by knot insertion into blending
|
||||
//! functions; Scott, Li, Sederberg, Hughes 2012, "Local refinement of analysis-suitable
|
||||
//! T-splines" (CMAME 213–216) — §2 index-space T-meshes and T-junction extensions; Piegl &
|
||||
//! Tiller, *The NURBS Book* — the univariate basis.
|
||||
//!
|
||||
//! Scope: odd degree in each direction (control points at vertices, as in the 2003 paper;
|
||||
//! even degrees put anchors on faces and are refused), rectangular index domains.
|
||||
//!
|
||||
//! The surface is `S(s, t) = Σ wₖ Pₖ Bₖ(s, t) / Σ wₖ Bₖ(s, t)` with `Bₖ(s, t) = N[sₖ](s) ·
|
||||
//! N[tₖ](t)`, the univariate B-splines of the vertex's local knot vectors (2003, eq. 2–3).
|
||||
//! Near the boundary the outermost `(p−1)/2` index lines each way form a frame that must be
|
||||
//! complete lines; the domain is the rectangle inside it.
|
||||
|
||||
mod extract;
|
||||
mod mesh;
|
||||
mod refine;
|
||||
|
||||
pub use crate::bspline::Dir;
|
||||
pub use mesh::{Extension, IndexRect, MAX_GRID_POINTS, MAX_INDEX_LINES, Segment, TMesh};
|
||||
pub use refine::Cut;
|
||||
|
||||
use crate::Vec3;
|
||||
use crate::bspline::{HPoint, MAX_SPLINE_DEGREE, Side, SplineError, SplineResult, invalid, local_basis, weighted_points};
|
||||
|
||||
/// A blending function: its anchor's local knot vectors as line indices and as values, and
|
||||
/// its support.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Blend {
|
||||
s: Vec<usize>,
|
||||
t: Vec<usize>,
|
||||
sv: Vec<f64>,
|
||||
tv: Vec<f64>,
|
||||
}
|
||||
|
||||
impl Blend {
|
||||
fn support(&self) -> [[f64; 2]; 2] {
|
||||
let ends = |v: &[f64]| [v.first().copied().unwrap_or(0.0), v.last().copied().unwrap_or(0.0)];
|
||||
[ends(&self.sv), ends(&self.tv)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of frame lines outside the domain on each side, `(p−1)/2` per direction.
|
||||
fn frame(degree: [usize; 2]) -> [usize; 2] {
|
||||
degree.map(|p| p.saturating_sub(1) / 2)
|
||||
}
|
||||
|
||||
/// A T-spline surface of odd bi-degree.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TSpline {
|
||||
mesh: TMesh,
|
||||
degree: [usize; 2],
|
||||
ctrl: Vec<HPoint>,
|
||||
blends: Vec<Blend>,
|
||||
}
|
||||
|
||||
impl TSpline {
|
||||
/// A T-spline from a mesh, a bi-degree and one point and positive weight per vertex (in
|
||||
/// [`TMesh::vertices`] order).
|
||||
pub fn new(mesh: TMesh, degree: [usize; 2], points: &[Vec3], weights: &[f64]) -> SplineResult<TSpline> {
|
||||
TSpline::from_homogeneous(mesh, degree, weighted_points(points, weights)?)
|
||||
}
|
||||
|
||||
/// A T-spline from homogeneous control points (`(x·w, y·w, z·w, w)`, `w > 0`).
|
||||
pub fn from_homogeneous(mesh: TMesh, degree: [usize; 2], ctrl: Vec<HPoint>) -> SplineResult<TSpline> {
|
||||
for p in degree {
|
||||
if p == 0 || p > MAX_SPLINE_DEGREE {
|
||||
return Err(SplineError::Unsupported(format!("degree {p} (1 to {MAX_SPLINE_DEGREE})")));
|
||||
}
|
||||
if p % 2 == 0 {
|
||||
return Err(SplineError::Unsupported(format!("even degree {p} (T-splines here have odd degrees)")));
|
||||
}
|
||||
}
|
||||
let g = &mesh.grid;
|
||||
let (ns, nt) = (g.ns(), g.nt());
|
||||
let [cs, ct] = frame(degree);
|
||||
if ns < 2 * cs + 2 || nt < 2 * ct + 2 {
|
||||
return invalid("too few index lines for the degree");
|
||||
}
|
||||
let full_col = |i: usize| (0..nt - 1).all(|j| g.v(i, j));
|
||||
let full_row = |j: usize| (0..ns - 1).all(|i| g.h(i, j));
|
||||
if (0..=cs).any(|k| !full_col(k) || !full_col(ns - 1 - k)) || (0..=ct).any(|k| !full_row(k) || !full_row(nt - 1 - k)) {
|
||||
return invalid("the outermost (p−1)/2 index lines must be complete");
|
||||
}
|
||||
if !(g.s.get(cs) < g.s.get(ns - 1 - cs)) || !(g.t.get(ct) < g.t.get(nt - 1 - ct)) {
|
||||
return invalid("the T-spline's domain is empty");
|
||||
}
|
||||
if ctrl.len() != mesh.vertices().len() {
|
||||
return invalid(format!("{} control points for {} vertices", ctrl.len(), mesh.vertices().len()));
|
||||
}
|
||||
if ctrl.iter().any(|h| !h.is_finite() || !(h.w > 0.0) || h.project().is_none()) {
|
||||
return invalid("control points must be finite with positive weights");
|
||||
}
|
||||
let mut blends = Vec::with_capacity(ctrl.len());
|
||||
for &(i, j) in mesh.vertices() {
|
||||
let s = g.local_knots(i, j, Dir::S, degree[0]);
|
||||
let t = g.local_knots(i, j, Dir::T, degree[1]);
|
||||
let sv: Vec<f64> = s.iter().map(|&x| g.s.get(x).copied().unwrap_or(0.0)).collect();
|
||||
let tv: Vec<f64> = t.iter().map(|&y| g.t.get(y).copied().unwrap_or(0.0)).collect();
|
||||
let b = Blend { s, t, sv, tv };
|
||||
let [[s0, s1], [t0, t1]] = b.support();
|
||||
if !(s0 < s1 && t0 < t1) {
|
||||
return invalid(format!("repeated knots leave the blending function at ({i}, {j}) empty"));
|
||||
}
|
||||
blends.push(b);
|
||||
}
|
||||
Ok(TSpline { mesh, degree, ctrl, blends })
|
||||
}
|
||||
|
||||
pub fn mesh(&self) -> &TMesh {
|
||||
&self.mesh
|
||||
}
|
||||
pub fn degree(&self) -> [usize; 2] {
|
||||
self.degree
|
||||
}
|
||||
/// Homogeneous control points, one per vertex.
|
||||
pub fn homogeneous(&self) -> &[HPoint] {
|
||||
&self.ctrl
|
||||
}
|
||||
/// Cartesian control points, one per vertex.
|
||||
pub fn points(&self) -> Vec<Vec3> {
|
||||
self.ctrl.iter().map(|h| h.project().unwrap_or(Vec3::ZERO)).collect()
|
||||
}
|
||||
/// Weights, one per vertex.
|
||||
pub fn weights(&self) -> Vec<f64> {
|
||||
self.ctrl.iter().map(|h| h.w).collect()
|
||||
}
|
||||
/// The local knot vectors (values) of vertex `k`'s blending function.
|
||||
pub fn local_knots(&self, k: usize) -> Option<(&[f64], &[f64])> {
|
||||
self.blends.get(k).map(|b| (b.sv.as_slice(), b.tv.as_slice()))
|
||||
}
|
||||
|
||||
/// The parameter domain `[[s₀, s₁], [t₀, t₁]]`.
|
||||
pub fn domain(&self) -> [[f64; 2]; 2] {
|
||||
let g = &self.mesh.grid;
|
||||
let [cs, ct] = frame(self.degree);
|
||||
let at = |k: &[f64], i: usize| k.get(i).copied().unwrap_or(0.0);
|
||||
[[at(&g.s, cs), at(&g.s, g.ns() - 1 - cs)], [at(&g.t, ct), at(&g.t, g.nt() - 1 - ct)]]
|
||||
}
|
||||
|
||||
/// Analysis-suitable (no s-extension meets a t-extension; Scott et al. 2012, §2).
|
||||
pub fn is_analysis_suitable(&self) -> bool {
|
||||
self.mesh.is_analysis_suitable(self.degree)
|
||||
}
|
||||
|
||||
/// Which side of the knots to evaluate on, after checking `(s, t)` lies in the domain.
|
||||
fn sides(&self, s: f64, t: f64) -> SplineResult<[Side; 2]> {
|
||||
let [ds, dt] = self.domain();
|
||||
if !(s >= ds[0] && s <= ds[1] && t >= dt[0] && t <= dt[1]) {
|
||||
return invalid(format!("({s}, {t}) is outside the T-spline's domain"));
|
||||
}
|
||||
let side = |x: f64, hi: f64| if x >= hi { Side::Left } else { Side::Right };
|
||||
Ok([side(s, ds[1]), side(t, dt[1])])
|
||||
}
|
||||
|
||||
/// Blending function `k` at `(s, t)`, with its first derivatives `[B, ∂B/∂s, ∂B/∂t]`.
|
||||
fn blend_at(&self, b: &Blend, s: f64, t: f64, sides: [Side; 2], n: usize) -> [f64; 3] {
|
||||
let [[s0, s1], [t0, t1]] = b.support();
|
||||
if s < s0 || s > s1 || t < t0 || t > t1 {
|
||||
return [0.0; 3];
|
||||
}
|
||||
let ns = local_basis(&b.sv, s, sides[0], n);
|
||||
let nt = local_basis(&b.tv, t, sides[1], n);
|
||||
let (n0, n1) = (ns.first().copied().unwrap_or(0.0), ns.get(1).copied().unwrap_or(0.0));
|
||||
let (m0, m1) = (nt.first().copied().unwrap_or(0.0), nt.get(1).copied().unwrap_or(0.0));
|
||||
[n0 * m0, n1 * m0, n0 * m1]
|
||||
}
|
||||
|
||||
/// The value of vertex `k`'s blending function `Bₖ(s, t)` (no weight).
|
||||
pub fn blending(&self, k: usize, s: f64, t: f64) -> SplineResult<f64> {
|
||||
let sides = self.sides(s, t)?;
|
||||
let b = self.blends.get(k).ok_or_else(|| SplineError::Invalid(format!("no vertex {k}")))?;
|
||||
Ok(self.blend_at(b, s, t, sides, 0)[0])
|
||||
}
|
||||
|
||||
/// Homogeneous sums `[A, ∂A/∂s, ∂A/∂t]` at `(s, t)`.
|
||||
fn sums(&self, s: f64, t: f64, n: usize) -> SplineResult<[HPoint; 3]> {
|
||||
let sides = self.sides(s, t)?;
|
||||
let mut acc = [HPoint::ZERO; 3];
|
||||
for (b, c) in self.blends.iter().zip(&self.ctrl) {
|
||||
let v = self.blend_at(b, s, t, sides, n);
|
||||
for (a, x) in acc.iter_mut().zip(v) {
|
||||
if x != 0.0 {
|
||||
*a += *c * x;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(acc)
|
||||
}
|
||||
|
||||
/// The point at `(s, t)`.
|
||||
pub fn eval(&self, s: f64, t: f64) -> SplineResult<Vec3> {
|
||||
let [a, _, _] = self.sums(s, t, 0)?;
|
||||
a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))
|
||||
}
|
||||
|
||||
/// The point and the first partial derivatives `[S, ∂S/∂s, ∂S/∂t]` at `(s, t)` (quotient
|
||||
/// rule on the homogeneous sums).
|
||||
pub fn derivs(&self, s: f64, t: f64) -> SplineResult<[Vec3; 3]> {
|
||||
let [a, a_s, a_t] = self.sums(s, t, 1)?;
|
||||
let p = a.project().ok_or_else(|| SplineError::Invalid(format!("the blending functions vanish at ({s}, {t})")))?;
|
||||
let d = |h: HPoint| (h.xyz() - p * h.w) / a.w;
|
||||
Ok([p, d(a_s), d(a_t)])
|
||||
}
|
||||
|
||||
/// The unit normal `∂S/∂s × ∂S/∂t` at `(s, t)`, if the surface is regular there.
|
||||
pub fn normal(&self, s: f64, t: f64) -> SplineResult<Option<Vec3>> {
|
||||
let [_, ds, dt] = self.derivs(s, t)?;
|
||||
Ok(ds.cross(dt).normalized())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Local refinement that keeps the surface exactly (Sederberg, Cardon, Finnigan, North,
|
||||
//! Zheng, Lyche 2004, §3).
|
||||
//!
|
||||
//! The requested knot lines are cut into the T-mesh first. Then every blending function of
|
||||
//! the original surface is carried as a record — its local knot vectors (as index lines) and
|
||||
//! its homogeneous coefficient — and the records are made to agree with the refined mesh:
|
||||
//!
|
||||
//! * **Violation 1** — the mesh's ray from a record's anchor crosses a line inside the
|
||||
//! record's support that the record lacks: insert that knot into the record, which splits
|
||||
//! it into two records (knot insertion into one blending function, 2004 §3).
|
||||
//! * **Violation 2** — the record has a knot where the ray crosses nothing: split the face
|
||||
//! there so the mesh dictates that knot.
|
||||
//! * **Violation 3** — a record's anchor is not a vertex: split a face so it becomes one.
|
||||
//!
|
||||
//! Records with the same knot vectors merge. When nothing is violated, each record is exactly
|
||||
//! the blending function of the vertex at its anchor, so summing the coefficients per vertex
|
||||
//! gives control points of the refined T-spline, and the surface is unchanged. All knots are
|
||||
//! handled as line indices, so every comparison is exact; no new index line appears after
|
||||
//! the cuts, so the work is bounded by the grid.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::TSpline;
|
||||
use super::mesh::{Grid, TMesh};
|
||||
use crate::bspline::{Dir, HPoint, SplineError, SplineResult, invalid, split_basis, split_knots};
|
||||
|
||||
/// Most cuts in one refinement.
|
||||
const MAX_CUTS: usize = 1 << 12;
|
||||
/// Most record checks and mesh changes in one refinement.
|
||||
const MAX_REFINE_STEPS: usize = 1 << 22;
|
||||
|
||||
/// A knot line to cut into a T-spline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Cut {
|
||||
/// The line `s = s` from `t = t0` to `t = t1`: every face it crosses there is split.
|
||||
S { s: f64, t0: f64, t1: f64 },
|
||||
/// The line `t = t` from `s = s0` to `s = s1`.
|
||||
T { t: f64, s0: f64, s1: f64 },
|
||||
}
|
||||
|
||||
impl Cut {
|
||||
/// The direction the cut line is constant in: [`Dir::S`] for a line `s = …`.
|
||||
fn dir(&self) -> Dir {
|
||||
match self {
|
||||
Cut::S { .. } => Dir::S,
|
||||
Cut::T { .. } => Dir::T,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A record's knot vectors (s, t) as line indices.
|
||||
type Key = (Vec<usize>, Vec<usize>);
|
||||
|
||||
enum Fix {
|
||||
/// Violation 1: insert line `k` into the record along the direction.
|
||||
Split(Dir, usize),
|
||||
/// Violations 2 and 3: the point (column, row) must lie on a column edge.
|
||||
Column(usize, usize),
|
||||
/// … on a row edge.
|
||||
Row(usize, usize),
|
||||
}
|
||||
|
||||
impl TSpline {
|
||||
/// The same surface on a refined T-mesh with the given knot lines cut in (2004 §3; see the
|
||||
/// module notes). The cut lines must lie strictly inside the domain; a cut at the value
|
||||
/// of an existing knot line extends that line. Further faces may be split to keep the
|
||||
/// surface exact and, when this T-spline is analysis-suitable, to keep the result
|
||||
/// analysis-suitable (T-junctions are carried across faces until no s-extension meets a
|
||||
/// t-extension; Scott et al. 2012).
|
||||
pub fn refine(&self, cuts: &[Cut]) -> SplineResult<TSpline> {
|
||||
if cuts.len() > MAX_CUTS {
|
||||
return Err(SplineError::TooLarge(format!("{} cuts (at most {MAX_CUTS})", cuts.len())));
|
||||
}
|
||||
let mut g = self.mesh.grid.clone();
|
||||
let mut smap: Vec<usize> = (0..g.ns()).collect();
|
||||
let mut tmap: Vec<usize> = (0..g.nt()).collect();
|
||||
let [ds, dt] = self.domain();
|
||||
// Row cuts run as column cuts on the transposed grid; consecutive cuts along one
|
||||
// direction share a transposition.
|
||||
for run in cuts.chunk_by(|a, b| a.dir() == b.dir()) {
|
||||
let along_t = run.first().is_some_and(|c| c.dir() == Dir::T);
|
||||
if along_t {
|
||||
g = g.transposed();
|
||||
}
|
||||
for cut in run {
|
||||
match *cut {
|
||||
Cut::S { s, t0, t1 } => cut_column(&mut g, &mut smap, s, [t0, t1], ds)?,
|
||||
Cut::T { t, s0, s1 } => cut_column(&mut g, &mut tmap, t, [s0, s1], dt)?,
|
||||
}
|
||||
}
|
||||
if along_t {
|
||||
g = g.transposed();
|
||||
}
|
||||
}
|
||||
let remap = |idx: &[usize], map: &[usize]| -> Vec<usize> { idx.iter().map(|&x| map.get(x).copied().unwrap_or(x)).collect() };
|
||||
let mut rec: BTreeMap<Key, HPoint> = BTreeMap::new();
|
||||
for (b, c) in self.blends.iter().zip(&self.ctrl) {
|
||||
*rec.entry((remap(&b.s, &smap), remap(&b.t, &tmap))).or_insert(HPoint::ZERO) += *c;
|
||||
}
|
||||
// Keep analysis-suitability (Scott et al. 2012): while an s-extension meets a
|
||||
// t-extension, carry the s-extension's T-junction across its face, then make the
|
||||
// records agree with the mesh again. Every round adds edges, so this ends.
|
||||
let keep_suitable = self.is_analysis_suitable();
|
||||
let mesh = loop {
|
||||
close(&mut g, &mut rec, self.degree)?;
|
||||
let mesh = TMesh::from_grid(g.clone())?;
|
||||
match mesh.conflict(self.degree).filter(|_| keep_suitable) {
|
||||
Some(e) => extend_t_junction(&mut g, e.from),
|
||||
None => break mesh,
|
||||
}
|
||||
};
|
||||
let mut ctrl = vec![HPoint::ZERO; mesh.vertices().len()];
|
||||
let mut seen = vec![false; ctrl.len()];
|
||||
for ((s, t), c) in &rec {
|
||||
let (ci, cj) = (centre(s), centre(t));
|
||||
let k = mesh.vertex_at(ci, cj).ok_or_else(|| SplineError::Invalid("refinement left a blending function without a vertex".into()))?;
|
||||
if let (Some(slot), Some(f)) = (ctrl.get_mut(k), seen.get_mut(k)) {
|
||||
*slot += *c;
|
||||
*f = true;
|
||||
}
|
||||
}
|
||||
if let Some(k) = seen.iter().position(|&f| !f) {
|
||||
let (i, j) = mesh.vertices().get(k).copied().unwrap_or_default();
|
||||
return Err(SplineError::Unsupported(format!(
|
||||
"this refinement leaves the control point at ({i}, {j}) without a blending function (the refined T-mesh is not analysis-suitable)"
|
||||
)));
|
||||
}
|
||||
let out = TSpline::from_homogeneous(mesh, self.degree, ctrl)?;
|
||||
// The records are the refined blending functions, one per vertex.
|
||||
for (s, t) in rec.keys() {
|
||||
let k = out.mesh.vertex_at(centre(s), centre(t));
|
||||
if k.and_then(|k| out.blends.get(k)).is_none_or(|b| &b.s != s || &b.t != t) {
|
||||
return Err(SplineError::Invalid("refinement did not converge to the refined mesh's blending functions".into()));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry a T-junction's missing edge across the face beyond it.
|
||||
fn extend_t_junction(g: &mut Grid, (i, j): (usize, usize)) {
|
||||
let (l, r, d, u) = (i > 0 && g.h(i - 1, j), g.h(i, j), j > 0 && g.v(i, j - 1), g.v(i, j));
|
||||
if !r {
|
||||
g.split_row_at(i, j);
|
||||
} else if !l {
|
||||
g.split_row_at(i.saturating_sub(1), j);
|
||||
} else if !u {
|
||||
g.split_column_at(i, j);
|
||||
} else if !d {
|
||||
g.split_column_at(i, j.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// The anchor line of a local knot vector.
|
||||
fn centre(k: &[usize]) -> usize {
|
||||
k.get(k.len() / 2).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cut the column line `value` into every face it crosses between `range[0]` and `range[1]`,
|
||||
/// adding the column if needed.
|
||||
fn cut_column(g: &mut Grid, map: &mut [usize], value: f64, range: [f64; 2], dom: [f64; 2]) -> SplineResult<()> {
|
||||
let [a, b] = range;
|
||||
if !(value.is_finite() && a.is_finite() && b.is_finite()) {
|
||||
return invalid("a cut needs finite numbers");
|
||||
}
|
||||
if !(a < b) {
|
||||
return invalid("a cut needs a non-empty range");
|
||||
}
|
||||
if !(value > dom[0] && value < dom[1]) {
|
||||
return invalid(format!("the cut at {value} is not inside the domain"));
|
||||
}
|
||||
let same: Vec<usize> = (0..g.ns()).filter(|&x| g.s.get(x) == Some(&value)).collect();
|
||||
let (x, new) = match same.as_slice() {
|
||||
[] => {
|
||||
let at = g.s.iter().position(|&k| k > value).unwrap_or(g.ns() - 1);
|
||||
g.insert_column(at, value)?;
|
||||
map.iter_mut().filter(|m| **m >= at).for_each(|m| *m += 1);
|
||||
(at, true)
|
||||
}
|
||||
[x] => (*x, false),
|
||||
_ => return Err(SplineError::Unsupported(format!("cutting along the repeated knot {value}"))),
|
||||
};
|
||||
let mut added = false;
|
||||
for y in 0..g.nt() - 1 {
|
||||
if g.v(x, y) {
|
||||
continue;
|
||||
}
|
||||
let f = g.face_of_cell(x, y);
|
||||
let (fa, fb) = (g.t.get(f.j0).copied().unwrap_or(0.0), g.t.get(f.j1).copied().unwrap_or(0.0));
|
||||
// Faces overlapping the range, and faces of zero height inside it (so a cut from a
|
||||
// clamped domain end runs on through the zero-height frame to the boundary).
|
||||
if fb.min(b) - fa.max(a) > 0.0 || (fa == fb && fa >= a && fb <= b) {
|
||||
(f.j0..f.j1).for_each(|j| g.set_v(x, j));
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if new && !added {
|
||||
return invalid(format!("the cut at {value} crosses no face"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the violations until every record is a blending function of the mesh.
|
||||
fn close(g: &mut Grid, rec: &mut BTreeMap<Key, HPoint>, degree: [usize; 2]) -> SplineResult<()> {
|
||||
let mut steps = 0usize;
|
||||
let mut tick = |n: usize| -> SplineResult<()> {
|
||||
steps += n;
|
||||
if steps > MAX_REFINE_STEPS { Err(SplineError::TooLarge("the refinement needs too much work".into())) } else { Ok(()) }
|
||||
};
|
||||
loop {
|
||||
// Violation 1 first (knot insertion into records), until none is left.
|
||||
loop {
|
||||
let keys: Vec<Key> = rec.keys().cloned().collect();
|
||||
tick(keys.len())?;
|
||||
let mut changed = false;
|
||||
for key in keys {
|
||||
if let Some(Fix::Split(dir, k)) = check(g, &key, degree)
|
||||
&& let Some(c) = rec.remove(&key)
|
||||
{
|
||||
for (piece, f) in split(g, &key, dir, k)? {
|
||||
if f != 0.0 {
|
||||
*rec.entry(piece).or_insert(HPoint::ZERO) += c * f;
|
||||
}
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Then one mesh change for the first record that needs one.
|
||||
tick(rec.len())?;
|
||||
let fix = rec.keys().find_map(|k| match check(g, k, degree) {
|
||||
Some(Fix::Split(..)) | None => None,
|
||||
other => other,
|
||||
});
|
||||
match fix {
|
||||
Some(Fix::Column(x, y)) => g.split_column_at(x, y),
|
||||
Some(Fix::Row(x, y)) => g.split_row_at(x, y),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first violation of a record against the mesh, if any.
|
||||
fn check(g: &Grid, key: &Key, degree: [usize; 2]) -> Option<Fix> {
|
||||
let (s, t) = key;
|
||||
let (ci, cj) = (centre(s), centre(t));
|
||||
let ms = g.local_knots(ci, cj, Dir::S, degree[0]);
|
||||
match compare(s, &ms) {
|
||||
Some(Ok(k)) => return Some(Fix::Split(Dir::S, k)),
|
||||
Some(Err(x)) => return Some(Fix::Column(x, cj)),
|
||||
None => {}
|
||||
}
|
||||
let mt = g.local_knots(ci, cj, Dir::T, degree[1]);
|
||||
match compare(t, &mt) {
|
||||
Some(Ok(k)) => return Some(Fix::Split(Dir::T, k)),
|
||||
Some(Err(y)) => return Some(Fix::Row(ci, y)),
|
||||
None => {}
|
||||
}
|
||||
if !g.on_column(ci, cj) {
|
||||
return Some(Fix::Column(ci, cj));
|
||||
}
|
||||
if !g.on_row(ci, cj) {
|
||||
return Some(Fix::Row(ci, cj));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Compare a record's knots with the mesh's, outward from the anchor: `Ok(k)` the mesh
|
||||
/// crosses line `k` first (insert it into the record), `Err(x)` the record has line `x` where
|
||||
/// the mesh crosses nothing (the mesh must gain it).
|
||||
fn compare(rec: &[usize], mesh: &[usize]) -> Option<Result<usize, usize>> {
|
||||
let mid = rec.len() / 2;
|
||||
for k in mid + 1..rec.len() {
|
||||
let (r, m) = (rec.get(k)?, mesh.get(k)?);
|
||||
if m < r {
|
||||
return Some(Ok(*m));
|
||||
}
|
||||
if m > r {
|
||||
return Some(Err(*r));
|
||||
}
|
||||
}
|
||||
for k in (0..mid).rev() {
|
||||
let (r, m) = (rec.get(k)?, mesh.get(k)?);
|
||||
if m > r {
|
||||
return Some(Ok(*m));
|
||||
}
|
||||
if m < r {
|
||||
return Some(Err(*r));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Insert line `k` into a record along `dir`: the two pieces and their factors (2004 §3).
|
||||
/// Pieces whose knots all have one value are the zero function and are dropped.
|
||||
fn split(g: &Grid, key: &Key, dir: Dir, k: usize) -> SplineResult<Vec<(Key, f64)>> {
|
||||
let (idx, vals) = dir.pick((&key.0, &g.s), (&key.1, &g.t));
|
||||
let value = |x: usize| vals.get(x).copied().unwrap_or(0.0);
|
||||
let values: Vec<f64> = idx.iter().map(|&x| value(x)).collect();
|
||||
let (c1, c2) = split_basis(&values, value(k)).ok_or_else(|| SplineError::Invalid("refinement inserted a knot outside a support".into()))?;
|
||||
let mut out = Vec::with_capacity(2);
|
||||
for (piece, c) in split_knots(idx, k).into_iter().zip([c1, c2]) {
|
||||
let (Some(&lo), Some(&hi)) = (piece.first(), piece.last()) else { continue };
|
||||
if value(lo) == value(hi) {
|
||||
continue;
|
||||
}
|
||||
let key = match dir {
|
||||
Dir::S => (piece, key.1.clone()),
|
||||
Dir::T => (key.0.clone(), piece),
|
||||
};
|
||||
out.push((key, c));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
use super::*;
|
||||
use crate::bspline::tests::{Lcg, random_surface};
|
||||
use crate::bspline::{BezierPatch, NurbsSurface, SplineError};
|
||||
|
||||
fn uniform(n: usize) -> Vec<f64> {
|
||||
(0..n).map(|k| k as f64).collect()
|
||||
}
|
||||
|
||||
fn col(i: usize, j0: usize, j1: usize) -> Segment {
|
||||
Segment::Column { i, j0, j1 }
|
||||
}
|
||||
fn row(j: usize, i0: usize, i1: usize) -> Segment {
|
||||
Segment::Row { j, i0, i1 }
|
||||
}
|
||||
|
||||
/// 7 × 7 lines, row 3 only from column 0 to column 3: one T-junction at (3, 3) whose
|
||||
/// extension runs along s.
|
||||
fn t_mesh() -> TMesh {
|
||||
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
|
||||
segs.extend([0, 1, 2, 4, 5, 6].map(|j| row(j, 0, 6)));
|
||||
segs.push(row(3, 0, 3));
|
||||
TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap()
|
||||
}
|
||||
|
||||
fn random_points(seed: u64, n: usize) -> (Vec<Vec3>, Vec<f64>) {
|
||||
let mut rng = Lcg(seed);
|
||||
let pts = (0..n).map(|_| Vec3::new(rng.unit(), rng.unit(), rng.unit()) * 10.0).collect();
|
||||
let w = (0..n).map(|_| 0.5 + rng.unit()).collect();
|
||||
(pts, w)
|
||||
}
|
||||
|
||||
fn t_spline(seed: u64) -> TSpline {
|
||||
let mesh = t_mesh();
|
||||
let (pts, w) = random_points(seed, mesh.vertices().len());
|
||||
TSpline::new(mesh, [3, 3], &pts, &w).unwrap()
|
||||
}
|
||||
|
||||
/// A clamped bicubic NURBS with uneven interior knots, as a T-spline.
|
||||
fn tensor_spline(seed: u64) -> (NurbsSurface, TSpline) {
|
||||
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
|
||||
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
|
||||
let n = random_surface(seed, ku, kv, [3, 3]);
|
||||
let ts = TSpline::from_nurbs(&n).unwrap();
|
||||
(n, ts)
|
||||
}
|
||||
|
||||
fn grid_params(dom: [[f64; 2]; 2], k: usize) -> Vec<(f64, f64)> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..=k {
|
||||
for j in 0..=k {
|
||||
let s = dom[0][0] + (dom[0][1] - dom[0][0]) * i as f64 / k as f64;
|
||||
let t = dom[1][0] + (dom[1][1] - dom[1][0]) * j as f64 / k as f64;
|
||||
out.push((s, t));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn max_deviation(a: &TSpline, b: &TSpline) -> f64 {
|
||||
assert_eq!(a.domain(), b.domain());
|
||||
grid_params(a.domain(), 60).into_iter().map(|(s, t)| a.eval(s, t).unwrap().dist(b.eval(s, t).unwrap())).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmesh_validation_refuses_malformed_meshes() {
|
||||
let frame = |extra: &[Segment]| -> Vec<Segment> {
|
||||
let mut s = vec![col(0, 0, 4), col(4, 0, 4), row(0, 0, 4), row(4, 0, 4)];
|
||||
s.extend_from_slice(extra);
|
||||
s
|
||||
};
|
||||
let full: Vec<Segment> = (1..4).flat_map(|k| [col(k, 0, 4), row(k, 0, 4)]).collect();
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&full)).is_ok());
|
||||
// A dangling edge, an L-shaped corner, an open boundary, an empty line.
|
||||
let mut dangling = full.clone();
|
||||
dangling.push(col(2, 0, 4));
|
||||
dangling.retain(|s| *s != row(2, 0, 4));
|
||||
dangling.push(row(2, 0, 1));
|
||||
dangling.push(row(2, 3, 4));
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &frame(&dangling)).is_ok(), "T-junctions on both sides are fine");
|
||||
let dangling_edge = frame(&[col(1, 0, 4), col(3, 0, 4), row(1, 0, 4), row(2, 0, 4), row(3, 0, 1), col(2, 0, 3)]);
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &dangling_edge).is_err(), "edge ending inside a face");
|
||||
let mut l_shape = frame(&[col(1, 0, 4), col(2, 0, 4), col(3, 0, 4), row(1, 0, 4), row(3, 0, 4)]);
|
||||
l_shape.push(row(2, 0, 2));
|
||||
l_shape.retain(|s| *s != col(2, 0, 4));
|
||||
l_shape.extend([col(2, 0, 2), col(2, 3, 4)]);
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &l_shape).is_err(), "L-shaped face corner");
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &full).is_err(), "no outer rectangle");
|
||||
let mut empty = frame(&full);
|
||||
empty.retain(|s| *s != col(2, 0, 4));
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &empty).is_err(), "a column without edges");
|
||||
assert!(TMesh::from_segments(vec![0.0, 1.0, f64::NAN, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
|
||||
assert!(TMesh::from_segments(vec![0.0, 2.0, 1.0, 3.0, 4.0], uniform(5), &frame(&full)).is_err());
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(9, 0, 4)]).is_err());
|
||||
assert!(TMesh::from_segments(uniform(5), uniform(5), &[col(1, 3, 3)]).is_err());
|
||||
assert!(matches!(TMesh::tensor(uniform(MAX_INDEX_LINES + 1), uniform(2)), Err(SplineError::TooLarge(_))));
|
||||
assert!(matches!(TMesh::tensor(uniform(2048), uniform(2048)), Err(SplineError::TooLarge(_))));
|
||||
assert!(TMesh::tensor(uniform(1), uniform(3)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_knot_vectors_are_read_off_rays() {
|
||||
let ts = t_spline(1);
|
||||
let m = ts.mesh();
|
||||
assert!(m.vertex_at(4, 3).is_none(), "(4, 3) lies on a column edge only");
|
||||
assert!(m.vertex_at(3, 3).is_some(), "the T-junction is a vertex");
|
||||
let knots = |i, j| {
|
||||
let k = m.vertex_at(i, j).unwrap();
|
||||
let (s, t) = ts.local_knots(k).unwrap();
|
||||
(s.to_vec(), t.to_vec())
|
||||
};
|
||||
assert_eq!(knots(2, 3), (vec![0.0, 1.0, 2.0, 3.0, 4.0], vec![1.0, 2.0, 3.0, 4.0, 5.0]));
|
||||
assert_eq!(knots(3, 4).1, vec![2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
// The ray down column 4 passes (4, 3), which carries no row edge.
|
||||
assert_eq!(knots(4, 4).1, vec![1.0, 2.0, 4.0, 5.0, 6.0]);
|
||||
assert_eq!(knots(4, 2).1, vec![0.0, 1.0, 2.0, 4.0, 5.0]);
|
||||
// Rays leaving the mesh repeat the boundary line.
|
||||
assert_eq!(knots(0, 0), (vec![0.0, 0.0, 0.0, 1.0, 2.0], vec![0.0, 0.0, 0.0, 1.0, 2.0]));
|
||||
assert_eq!(knots(6, 5).1, vec![2.0, 4.0, 5.0, 6.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_tspline_is_the_tensor_nurbs_surface() {
|
||||
for (seed, degree, ku, kv) in [
|
||||
(2, [3, 3], vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0]),
|
||||
(3, [3, 1], vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 0.4, 1.0, 1.0]),
|
||||
(4, [5, 3], vec![0.0; 6].into_iter().chain([0.3, 0.7]).chain([1.0; 6]).collect(), vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]),
|
||||
] {
|
||||
let n = random_surface(seed, ku, kv, degree);
|
||||
let ts = TSpline::from_nurbs(&n).unwrap();
|
||||
assert!(ts.mesh().is_regular() && ts.is_analysis_suitable());
|
||||
assert_eq!(ts.domain(), n.domain());
|
||||
let worst = grid_params(n.domain(), 40).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
|
||||
assert!(worst < 1e-12, "degree {degree:?}: T-spline differs from NURBS by {worst}");
|
||||
assert_eq!(ts.to_nurbs().unwrap(), n, "NURBS → T-spline → NURBS is exact");
|
||||
}
|
||||
// An unclamped NURBS is clamped on the way in (same surface).
|
||||
let n = random_surface(5, uniform(10), uniform(9), [3, 3]);
|
||||
let ts = TSpline::from_nurbs(&n).unwrap();
|
||||
let worst = grid_params(n.domain(), 30).into_iter().map(|(s, t)| ts.eval(s, t).unwrap().dist(n.eval(s, t).unwrap())).fold(0.0, f64::max);
|
||||
assert!(worst < 1e-12, "unclamped: {worst}");
|
||||
assert!(TSpline::from_nurbs(&random_surface(6, uniform(9), uniform(9), [2, 3])).is_err(), "even degree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blending_functions_sum_to_one_with_t_junctions() {
|
||||
let ts = t_spline(1);
|
||||
assert!(ts.is_analysis_suitable());
|
||||
let n = ts.mesh().vertices().len();
|
||||
let mut worst = 0.0f64;
|
||||
for (s, t) in grid_params(ts.domain(), 64) {
|
||||
let sum: f64 = (0..n).map(|k| ts.blending(k, s, t).unwrap()).sum();
|
||||
worst = worst.max((sum - 1.0).abs());
|
||||
}
|
||||
assert!(worst < 1e-14, "partition of unity off by {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivatives_match_finite_differences() {
|
||||
let ts = t_spline(9);
|
||||
let h = 1e-6;
|
||||
let mut worst = 0.0f64;
|
||||
for (s, t) in grid_params([[1.05, 4.95], [1.05, 4.95]], 17) {
|
||||
let [_, ds, dt] = ts.derivs(s, t).unwrap();
|
||||
let fs = (ts.eval(s + h, t).unwrap() - ts.eval(s - h, t).unwrap()) / (2.0 * h);
|
||||
let ft = (ts.eval(s, t + h).unwrap() - ts.eval(s, t - h).unwrap()) / (2.0 * h);
|
||||
worst = worst.max((ds - fs).len()).max((dt - ft).len());
|
||||
}
|
||||
assert!(worst < 1e-6, "derivative error {worst}");
|
||||
assert!(ts.normal(3.0, 3.0).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_suitability_follows_t_junction_extensions() {
|
||||
assert!(tensor_spline(1).1.is_analysis_suitable());
|
||||
let ext = t_mesh().extensions([3, 3]);
|
||||
assert_eq!(ext, vec![Extension { dir: Dir::S, at: 3, range: [2, 5], from: (3, 3) }]);
|
||||
assert!(t_mesh().is_analysis_suitable([3, 3]));
|
||||
// Row 4 stops at column 4 (extension along s over columns 3..7 on row 4: column 5 has no
|
||||
// edge there) and column 5 stops at row 3 (extension along t over rows 2..6 on column 5):
|
||||
// they cross at (5, 4).
|
||||
let mut segs: Vec<Segment> = [0, 1, 2, 3, 4, 6, 7, 8].iter().map(|&i| col(i, 0, 8)).collect();
|
||||
segs.push(col(5, 0, 3));
|
||||
segs.extend([0, 1, 2, 3, 5, 6, 7, 8].map(|j| row(j, 0, 8)));
|
||||
segs.push(row(4, 0, 4));
|
||||
let m = TMesh::from_segments(uniform(9), uniform(9), &segs).unwrap();
|
||||
let ext = m.extensions([3, 3]);
|
||||
assert!(ext.contains(&Extension { dir: Dir::S, at: 4, range: [3, 7], from: (4, 4) }));
|
||||
assert!(ext.contains(&Extension { dir: Dir::T, at: 5, range: [2, 6], from: (5, 3) }));
|
||||
assert!(!m.is_analysis_suitable([3, 3]));
|
||||
// Refining it does not repair it (only suitable T-splines are kept suitable), but stays exact.
|
||||
let (pts, w) = random_points(4, m.vertices().len());
|
||||
let ts = TSpline::new(m, [3, 3], &pts, &w).unwrap();
|
||||
let r = ts.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 3.0 }]).unwrap();
|
||||
assert!(max_deviation(&ts, &r) < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_across_the_domain_is_boehm_knot_insertion() {
|
||||
let (n, ts) = tensor_spline(4);
|
||||
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.0, t1: 1.0 }, Cut::T { t: 0.75, s0: 0.0, s1: 1.0 }]).unwrap();
|
||||
assert!(r.mesh().is_regular());
|
||||
let want = n.insert_knot(Dir::S, 0.33).unwrap().insert_knot(Dir::T, 0.75).unwrap();
|
||||
let got = r.to_nurbs().unwrap();
|
||||
assert_eq!(got.knots(), want.knots());
|
||||
let worst = got.homogeneous().iter().zip(want.homogeneous()).map(|(a, b)| (*a - *b).xyz().len() + (a.w - b.w).abs()).fold(0.0, f64::max);
|
||||
assert!(worst < 1e-12, "control points differ by {worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_refinement_keeps_the_surface() {
|
||||
let (_, ts) = tensor_spline(7);
|
||||
let before = ts.mesh().vertices().len();
|
||||
let r = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }]).unwrap();
|
||||
let dev = max_deviation(&ts, &r);
|
||||
eprintln!("local cut: {} → {} vertices, AS {}, max deviation {dev:e}", before, r.mesh().vertices().len(), r.is_analysis_suitable());
|
||||
assert!(dev < 1e-12, "deviation {dev}");
|
||||
assert!(r.mesh().vertices().len() <= before + 4, "a one-face cut stays local");
|
||||
assert!(r.is_analysis_suitable());
|
||||
// Refining again near the new T-junctions, and across them.
|
||||
let r2 = r.refine(&[Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }, Cut::S { s: 0.39, t0: 0.3, t1: 0.45 }]).unwrap();
|
||||
let dev2 = max_deviation(&ts, &r2);
|
||||
eprintln!("second refinement: {} vertices, max deviation {dev2:e}", r2.mesh().vertices().len());
|
||||
assert!(dev2 < 1e-12, "deviation {dev2}");
|
||||
assert!(r2.is_analysis_suitable(), "refinement keeps analysis-suitability");
|
||||
let n2 = r2.mesh().vertices().len();
|
||||
let pu = grid_params(r2.domain(), 50)
|
||||
.into_iter()
|
||||
.map(|(s, t)| ((0..n2).map(|k| r2.blending(k, s, t).unwrap()).sum::<f64>() - 1.0).abs())
|
||||
.fold(0.0, f64::max);
|
||||
assert!(pu < 1e-14, "partition of unity after refinement off by {pu}");
|
||||
// A rational T-spline with T-junctions.
|
||||
let t = t_spline(3);
|
||||
let r3 = t.refine(&[Cut::S { s: 2.5, t0: 2.0, t1: 4.0 }, Cut::T { t: 3.5, s0: 3.0, s1: 5.0 }]).unwrap();
|
||||
let dev3 = max_deviation(&t, &r3);
|
||||
eprintln!("rational T-spline: {} → {} vertices, max deviation {dev3:e}", t.mesh().vertices().len(), r3.mesh().vertices().len());
|
||||
assert!(dev3 < 1e-12, "deviation {dev3}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_local_refinements_keep_the_surface() {
|
||||
let mut rng = Lcg(42);
|
||||
let mut worst = 0.0f64;
|
||||
let mut vertices = 0;
|
||||
for round in 0..40 {
|
||||
let (_, ts) = tensor_spline(round);
|
||||
let mut cur = ts.clone();
|
||||
for _ in 0..1 + (rng.unit() * 3.0) as usize {
|
||||
let v = 0.05 + 0.9 * (rng.unit() * 40.0).round() / 40.0;
|
||||
let a = (rng.unit() * 8.0).round() / 10.0;
|
||||
let b = (a + 0.1 + (rng.unit() * 5.0).round() / 10.0).min(1.0);
|
||||
let cut = if rng.unit() < 0.5 { Cut::S { s: v, t0: a, t1: b } } else { Cut::T { t: v, s0: a, s1: b } };
|
||||
cur = cur.refine(&[cut]).unwrap_or_else(|e| panic!("round {round}: {cut:?}: {e}"));
|
||||
}
|
||||
assert!(cur.is_analysis_suitable(), "round {round}: refinement keeps analysis-suitability");
|
||||
vertices += cur.mesh().vertices().len() - ts.mesh().vertices().len();
|
||||
worst = worst.max(max_deviation(&ts, &cur));
|
||||
}
|
||||
eprintln!("random refinements: max deviation {worst:e}, {vertices} vertices added over 40 rounds");
|
||||
assert!(worst < 1e-12, "deviation {worst}");
|
||||
}
|
||||
|
||||
fn check_patches(ts: &TSpline, patches: &[BezierPatch]) -> f64 {
|
||||
let [ds, dt] = ts.domain();
|
||||
let area: f64 = patches.iter().map(|p| (p.s[1] - p.s[0]) * (p.t[1] - p.t[0])).sum();
|
||||
assert!((area - (ds[1] - ds[0]) * (dt[1] - dt[0])).abs() < 1e-12, "the patches tile the domain");
|
||||
let mut worst = 0.0f64;
|
||||
for p in patches {
|
||||
for (s, t) in grid_params([p.s, p.t], 6) {
|
||||
worst = worst.max(p.eval(s, t).unwrap().dist(ts.eval(s, t).unwrap()));
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bezier_extraction_reproduces_the_surface() {
|
||||
let ts = t_spline(5);
|
||||
let patches = ts.bezier_elements().unwrap();
|
||||
let dev = check_patches(&ts, &patches);
|
||||
eprintln!("Bézier extraction, T-junction mesh: {} patches, max deviation {dev:e}", patches.len());
|
||||
assert!(dev < 1e-12);
|
||||
let (n, tensor) = tensor_spline(8);
|
||||
let patches = tensor.bezier_elements().unwrap();
|
||||
assert_eq!(patches.len(), 5 * 3, "one patch per pair of knot spans");
|
||||
assert!(check_patches(&tensor, &patches) < 1e-12);
|
||||
let _ = n;
|
||||
let refined = tensor.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
|
||||
let patches = refined.bezier_elements().unwrap();
|
||||
let dev = check_patches(&refined, &patches);
|
||||
eprintln!("Bézier extraction, refined: {} patches, max deviation {dev:e}", patches.len());
|
||||
assert!(dev < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tspline_input_is_refused() {
|
||||
let mesh = t_mesh();
|
||||
let n = mesh.vertices().len();
|
||||
let (pts, w) = random_points(1, n);
|
||||
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &w).is_ok());
|
||||
let mut bad = pts.clone();
|
||||
bad[3] = Vec3::new(f64::NAN, 0.0, 0.0);
|
||||
assert!(TSpline::new(mesh.clone(), [3, 3], &bad, &w).is_err());
|
||||
let mut bw = w.clone();
|
||||
bw[0] = 0.0;
|
||||
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
|
||||
bw[0] = f64::INFINITY;
|
||||
assert!(TSpline::new(mesh.clone(), [3, 3], &pts, &bw).is_err());
|
||||
assert!(TSpline::new(mesh.clone(), [3, 3], &pts[1..], &w[1..]).is_err());
|
||||
assert!(matches!(TSpline::new(mesh.clone(), [2, 3], &pts, &w), Err(SplineError::Unsupported(_))));
|
||||
assert!(TSpline::new(mesh.clone(), [0, 3], &pts, &w).is_err());
|
||||
assert!(TSpline::new(mesh.clone(), [9, 3], &pts, &w).is_err());
|
||||
// Degree 5 needs the three outermost lines each way complete; row 3 is not, so a mesh
|
||||
// whose row 2 stops short is refused.
|
||||
let mut segs: Vec<Segment> = (0..7).map(|i| col(i, 0, 6)).collect();
|
||||
segs.extend([0, 1, 3, 4, 5, 6].map(|j| row(j, 0, 6)));
|
||||
segs.push(row(2, 0, 3));
|
||||
let short_frame = TMesh::from_segments(uniform(7), uniform(7), &segs).unwrap();
|
||||
let (p6, w6) = random_points(3, short_frame.vertices().len());
|
||||
assert!(TSpline::new(short_frame.clone(), [3, 3], &p6, &w6).is_ok());
|
||||
assert!(TSpline::new(short_frame, [5, 5], &p6, &w6).is_err());
|
||||
// A row of knots repeated so often that a blending function is empty.
|
||||
let m = TMesh::tensor(vec![0.0, 0.0, 0.0, 1.0, 2.0], uniform(5)).unwrap();
|
||||
let (p, w5) = random_points(2, 25);
|
||||
assert!(TSpline::new(m, [3, 3], &p, &w5).is_err());
|
||||
// An empty domain.
|
||||
let m = TMesh::tensor(vec![0.0, 1.0, 1.0, 2.0], uniform(4)).unwrap();
|
||||
assert!(TSpline::new(m, [3, 3], &p[..16], &w5[..16]).is_err());
|
||||
|
||||
let ts = TSpline::new(mesh, [3, 3], &pts, &w).unwrap();
|
||||
for (s, t) in [(f64::NAN, 2.0), (2.0, f64::INFINITY), (0.5, 2.0), (2.0, 5.5)] {
|
||||
assert!(ts.eval(s, t).is_err() && ts.derivs(s, t).is_err() && ts.blending(0, s, t).is_err());
|
||||
}
|
||||
assert!(ts.blending(n + 5, 2.0, 2.0).is_err());
|
||||
for cut in [
|
||||
Cut::S { s: f64::NAN, t0: 1.0, t1: 2.0 },
|
||||
Cut::S { s: 2.5, t0: 2.0, t1: 1.0 },
|
||||
Cut::S { s: 2.5, t0: 2.0, t1: f64::INFINITY },
|
||||
Cut::S { s: 0.5, t0: 1.0, t1: 2.0 },
|
||||
Cut::T { t: 5.0, s0: 1.0, s1: 2.0 },
|
||||
Cut::T { t: 9.0, s0: 1.0, s1: 2.0 },
|
||||
Cut::S { s: 2.5, t0: 8.0, t1: 9.0 },
|
||||
] {
|
||||
assert!(ts.refine(&[cut]).is_err(), "{cut:?}");
|
||||
}
|
||||
assert!(matches!(ts.refine(&vec![Cut::S { s: 2.5, t0: 1.0, t1: 2.0 }; 5000]), Err(SplineError::TooLarge(_))));
|
||||
// Refining the same line twice is a no-op the second time.
|
||||
let a = ts.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
|
||||
let b = a.refine(&[Cut::S { s: 2.5, t0: 1.0, t1: 5.0 }]).unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
+186
-88
@@ -209,15 +209,27 @@ fn blend_geometry(
|
||||
) -> Result<(Solid, Rebuild)> {
|
||||
let s = setback(r, phi, shape);
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
// End faces and the side edges at each end. A side edge that the fillet's setback uses up
|
||||
// exactly is consumed: the rail ends at its far vertex (`ca`, `cb`).
|
||||
// The arc centre is r from both faces (inside the material for a convex edge, outside for a
|
||||
// concave one), in the cross-section through `p`.
|
||||
let centre = |p: Vec3| p + t1 * s + n1 * (r * side);
|
||||
// Each end of the blend lies in one more face (the end face, square to the edge or at an
|
||||
// angle) or, for a fillet, in two that meet at the edge's end (Vida, Martin and Várady 1994:
|
||||
// trimlines joined across existing faces): the blend then crosses the edge between them,
|
||||
// which is cut back to the crossing. The rails run parallel to the edge, set back by s in
|
||||
// their side faces, and end in the end plane they reach (`ga` from f1, `gb` from f2). A side
|
||||
// edge that the setback uses up exactly is consumed: the rail ends at its far vertex (`ca`,
|
||||
// `cb`; one end face only).
|
||||
struct End {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
g: Vec<usize>,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
/// The edge between two end faces, and where the blend crosses it.
|
||||
e3: Option<(mt::Edge, Vec3)>,
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
/// The end's section of the blend, from A to B (across both faces when two).
|
||||
planes: Vec<(Vec3, Vec3)>,
|
||||
ca: Option<mt::Vertex>,
|
||||
cb: Option<mt::Vertex>,
|
||||
}
|
||||
@@ -225,70 +237,98 @@ fn blend_geometry(
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
|
||||
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != i1 && *i != i2).collect();
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if shape == Shape::Round && ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
if ng.dot(d).abs() < 1e-9 {
|
||||
return Err(unsupported("an end face parallel to the edge"));
|
||||
}
|
||||
// Each rail is parallel to the original edge and set back by s in its side
|
||||
// face. Slide its endpoint along d until it lies on the actual end plane.
|
||||
let endpoint = |t: Vec3| {
|
||||
let q = p + t * s;
|
||||
if shape == Shape::Flat { q - d * (ng.dot(t) * s / ng.dot(d)) } else { q }
|
||||
// The edge at v that face `fi` shares with face `g`, other than the blended edge.
|
||||
let shared = |fi: usize, g: usize| -> Option<mt::Edge> {
|
||||
let (f, gf) = (faces.get(fi)?, faces.get(g)?);
|
||||
f.edge_iter().find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
|
||||
};
|
||||
let (a, b) = (endpoint(t1), endpoint(t2));
|
||||
// Edges at v shared by g with f1 and with f2.
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
f.edge_iter()
|
||||
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
let (ga, gb) = match others[..] {
|
||||
[g] => (g, g),
|
||||
[x, y] if shape == Shape::Round && shared(i1, x).is_some() && shared(i2, y).is_some() => (x, y),
|
||||
[x, y] if shape == Shape::Round && shared(i1, y).is_some() && shared(i2, x).is_some() => (y, x),
|
||||
_ => return Err(unsupported("each end of the edge must touch exactly one more face")),
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
let normal = |g: usize| -> Result<Vec3> {
|
||||
let n = faces.get(g).and_then(|f| plane_normal(f)).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if n.dot(d).abs() < 1e-9 {
|
||||
return Err(unsupported("an end face parallel to the edge"));
|
||||
}
|
||||
Ok(n)
|
||||
};
|
||||
let (na, nb) = (normal(ga)?, normal(gb)?);
|
||||
// Slide a point of the cross-section through `p` along d onto the end plane through p.
|
||||
let slide = |x: Vec3, n: Vec3| x - d * (n.dot(x - p) / n.dot(d));
|
||||
let (a, b) = (slide(p + t1 * s, na), slide(p + t2 * s, nb));
|
||||
let (e1, e2) = (
|
||||
shared(i1, ga).ok_or_else(|| unsupported("unexpected corner topology"))?,
|
||||
shared(i2, gb).ok_or_else(|| unsupported("unexpected corner topology"))?,
|
||||
);
|
||||
let mut consumed: [Option<mt::Vertex>; 2] = [None, None];
|
||||
for (k, (e, q, t, fi)) in [(&e1, a, t1, i1), (&e2, b, t2, i2)].into_iter().enumerate() {
|
||||
for (k, (e, q, fi)) in [(&e1, a, i1), (&e2, b, i2)].into_iter().enumerate() {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let far = if e.front() == v { e.back().clone() } else { e.front().clone() };
|
||||
let other = vtx(&far);
|
||||
let delta = other - p;
|
||||
if shape == Shape::Round {
|
||||
// Fillets retain the square-end check: the original corner edge
|
||||
// must run in the setback direction and have enough length left,
|
||||
// or exactly the setback (the edge is consumed).
|
||||
let along = delta.dot(t);
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along < s - tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
if along <= s + tol
|
||||
&& let Some(slot) = consumed.get_mut(k)
|
||||
{
|
||||
let delta = vtx(&far) - p;
|
||||
let len = delta.len();
|
||||
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
|
||||
let along = (q - p).dot(dir);
|
||||
if (q - p - dir * along).len() > tol * 100.0 + 1e-7 {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
// A fillet's setback that uses up the side edge exactly consumes it.
|
||||
if shape == Shape::Round && ga == gb && (along - len).abs() <= tol {
|
||||
if let Some(slot) = consumed.get_mut(k) {
|
||||
*slot = Some(far);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let len = delta.len();
|
||||
let dir = delta.normalized().ok_or_else(|| unsupported("zero-length corner edge"))?;
|
||||
let along = (q - p).dot(dir);
|
||||
// An inner rim extends the neighbouring rim edge away from its other
|
||||
// vertex. This is valid only within the trimmed side face.
|
||||
if (q - p - dir * along).len() > tol * 100.0 + 1e-7
|
||||
|| along >= len - tol
|
||||
|| (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol)))
|
||||
{
|
||||
// A rail ending before the old corner extends the neighbouring rim edge away from its
|
||||
// other vertex: valid only within the trimmed side face.
|
||||
if along >= len - tol || (along < 0.0 && !faces.get(fi).is_some_and(|f| extension_fits(f, p, q, tol))) {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
// Across two end faces: where the blend's cylinder crosses the edge between them, on the
|
||||
// blend's side of the axis (between its two rails).
|
||||
let e3 = if ga == gb {
|
||||
None
|
||||
} else {
|
||||
let (Some(fa), Some(fb)) = (faces.get(ga), faces.get(gb)) else { return Err(KernelError::Failed("face".into())) };
|
||||
let e3 = fa
|
||||
.edge_iter()
|
||||
.find(|e| (e.front() == v || e.back() == v) && fb.edge_iter().any(|x| x.id() == e.id()))
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))?;
|
||||
if !matches!(e3.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let far = if e3.front() == v { vtx(e3.back()) } else { vtx(e3.front()) };
|
||||
let (len3, u) = (far.dist(p), (far - p).normalized().ok_or_else(|| unsupported("zero-length corner edge"))?);
|
||||
let c0 = centre(p);
|
||||
let perp = |x: Vec3| x - d * x.dot(d);
|
||||
let (w, up) = (perp(p - c0), perp(u));
|
||||
// |w + λ up|² = r²
|
||||
let (qa, qb, qc) = (up.len2(), 2.0 * w.dot(up), w.len2() - r * r);
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if qa < 1e-18 || disc < 0.0 {
|
||||
return Err(unsupported("the blend does not cross the edge between the end faces"));
|
||||
}
|
||||
let (ra, rb) = (perp(p + t1 * s - c0), perp(p + t2 * s - c0));
|
||||
let angle = |x: Vec3, y: Vec3| (x.dot(y) / (x.len() * y.len()).max(1e-300)).clamp(-1.0, 1.0).acos();
|
||||
let between = |x: Vec3| (angle(ra, x) + angle(x, rb) - angle(ra, rb)).abs() < 1e-6;
|
||||
let root = [(-qb - disc.sqrt()) / (2.0 * qa), (-qb + disc.sqrt()) / (2.0 * qa)]
|
||||
.into_iter()
|
||||
.filter(|l| *l > tol && *l < len3 - tol)
|
||||
.find(|l| between(w + up * *l))
|
||||
.ok_or_else(|| unsupported("the blend does not cross the edge between the end faces"))?;
|
||||
Some((e3.clone(), p + u * root))
|
||||
};
|
||||
let planes = if ga == gb { vec![(p, na)] } else { vec![(p, na), (p, nb)] };
|
||||
let [ca, cb] = consumed;
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b, ca, cb });
|
||||
ends.push(End { v: v.clone(), g: if ga == gb { vec![ga] } else { vec![ga, gb] }, e1, e2, e3, a, b, planes, ca, cb });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
if end0.g.iter().any(|g| end1.g.contains(g)) {
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
|
||||
@@ -316,42 +356,70 @@ fn blend_geometry(
|
||||
let rail_a = used_up(i1, &end0.ca, &end1.ca)?;
|
||||
let rail_b = used_up(i2, &end0.cb, &end1.cb)?;
|
||||
let vertex = |c: &Option<mt::Vertex>, p: Vec3| c.clone().unwrap_or_else(|| builder::vertex(p3(p)));
|
||||
let a0 = vertex(&end0.ca, end0.a);
|
||||
let b0 = vertex(&end0.cb, end0.b);
|
||||
let a1 = vertex(&end1.ca, end1.a);
|
||||
let b1 = vertex(&end1.cb, end1.b);
|
||||
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
|
||||
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
|
||||
// middle is the point nearest the old edge.
|
||||
let centre = |p: Vec3| p + t1 * s + n1 * (r * side);
|
||||
let arc_mid = |p: Vec3| {
|
||||
let c = centre(p);
|
||||
c + (p - c).normalized().unwrap_or(-n1) * r
|
||||
};
|
||||
let corner = |a: &mt::Vertex, b: &mt::Vertex, p: Vec3| -> mt::Edge {
|
||||
match shape {
|
||||
Shape::Round => builder::circle_arc(a, b, p3(arc_mid(p))),
|
||||
Shape::Flat => builder::line(a, b),
|
||||
// Corner curves on the end faces (A → B): the blend's cross-section through the end vertex,
|
||||
// slid along the edge onto each end plane (an ellipse arc; the circle itself on a square
|
||||
// end face).
|
||||
let section = |p: Vec3, va: &mt::Vertex, vb: &mt::Vertex, from: Vec3, to: Vec3, plane: (Vec3, Vec3)| -> Result<mt::Edge> {
|
||||
if shape == Shape::Flat {
|
||||
return Ok(builder::line(va, vb));
|
||||
}
|
||||
let c = centre(p);
|
||||
let mid = c + ((from - c) + (to - c)).normalized().unwrap_or(-n1) * r;
|
||||
let (o, n) = plane;
|
||||
if n.dot(d).abs() >= 1.0 - 1e-12 {
|
||||
return Ok(builder::circle_arc(va, vb, p3(mid)));
|
||||
}
|
||||
let arc = builder::circle_arc(&builder::vertex(p3(from)), &builder::vertex(p3(to)), p3(mid));
|
||||
let den = d.dot(n);
|
||||
// x ↦ x − d ((x − o)·n) / (d·n), column-major.
|
||||
let col = |j: usize| {
|
||||
let ej = [Vec3::X, Vec3::Y, Vec3::Z][j];
|
||||
ej - d * (ej.dot(n) / den)
|
||||
};
|
||||
let (c0, c1, c2) = (col(0), col(1), col(2));
|
||||
let tr = d * (o.dot(n) / den);
|
||||
let mat = mt::Matrix4::new(c0.x, c0.y, c0.z, 0.0, c1.x, c1.y, c1.z, 0.0, c2.x, c2.y, c2.z, 0.0, tr.x, tr.y, tr.z, 1.0);
|
||||
let mut curve = arc.curve();
|
||||
mt::Transformed::transform_by(&mut curve, mat);
|
||||
Ok(mt::Edge::new(va, vb, curve))
|
||||
};
|
||||
let c0 = corner(&a0, &b0, p0);
|
||||
let c1 = corner(&a1, &b1, p1);
|
||||
let la = rail_a.clone().unwrap_or_else(|| builder::line(&a0, &a1));
|
||||
let lb = rail_b.clone().unwrap_or_else(|| builder::line(&b0, &b1));
|
||||
let mut corners: Vec<(mt::Vertex, mt::Vertex, Vec<mt::Edge>, Option<mt::Vertex>)> = Vec::new();
|
||||
for (end, p) in [(end0, p0), (end1, p1)] {
|
||||
let (va, vb) = (vertex(&end.ca, end.a), vertex(&end.cb, end.b));
|
||||
let (sa, sb) = (p + t1 * s, p + t2 * s);
|
||||
let (chain, vq) = match (&end.e3, &end.planes[..]) {
|
||||
(None, [plane]) => (vec![section(p, &va, &vb, sa, sb, *plane)?], None),
|
||||
(Some((_, q)), [pa, pb]) => {
|
||||
let vq = builder::vertex(p3(*q));
|
||||
let sq = *q - d * (*q - p).dot(d);
|
||||
(vec![section(p, &va, &vq, sa, sq, *pa)?, section(p, &vq, &vb, sq, sb, *pb)?], Some(vq))
|
||||
}
|
||||
_ => return Err(KernelError::Failed("blend end".into())),
|
||||
};
|
||||
corners.push((va, vb, chain, vq));
|
||||
}
|
||||
let [(a0, b0, c0, q0), (a1, b1, c1, q1)] = &corners[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
let la = rail_a.clone().unwrap_or_else(|| builder::line(a0, a1));
|
||||
let lb = rail_b.clone().unwrap_or_else(|| builder::line(b0, b1));
|
||||
|
||||
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped); the
|
||||
// blended edge moves to A or B.
|
||||
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped), an edge
|
||||
// between two end faces stops where the blend crosses it; the blended edge moves to A or B.
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut dropped: Vec<mt::EdgeID> = Vec::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
let shorten = |e: &mt::Edge, v: &mt::Vertex, nv: &mt::Vertex| {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
if f == v { builder::line(nv, bk) } else { builder::line(f, nv) }
|
||||
};
|
||||
for (end, a, b, q) in [(end0, a0, b0, q0), (end1, a1, b1, q1)] {
|
||||
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
|
||||
if used.is_some() {
|
||||
dropped.push(e.id());
|
||||
continue;
|
||||
}
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
subst.insert(e.id(), shorten(e, &end.v, nv));
|
||||
}
|
||||
if let (Some((e3, _)), Some(q)) = (&end.e3, q) {
|
||||
subst.insert(e3.id(), shorten(e3, &end.v, q));
|
||||
}
|
||||
}
|
||||
// The side edges at the ends are pieces of themselves; the rails and corner curves are new.
|
||||
@@ -370,10 +438,12 @@ fn blend_geometry(
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == end0.g {
|
||||
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c0))?
|
||||
} else if i == end1.g {
|
||||
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c1))?
|
||||
} else if end0.g.contains(&i) {
|
||||
rebuild_face_dropping(f, &subst, &dropped, c0)?
|
||||
} else if end1.g.contains(&i) {
|
||||
rebuild_face_dropping(f, &subst, &dropped, c1)?
|
||||
} else if f.edge_iter().any(|e| subst.contains_key(&e.id())) {
|
||||
rebuild_face(f, &subst, &[])?
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
@@ -382,19 +452,39 @@ fn blend_geometry(
|
||||
}
|
||||
// The blend face uses each new edge opposite to its neighbour.
|
||||
let f1_forward = faces.get(i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0)));
|
||||
let wire: mt::Wire = if f1_forward {
|
||||
vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into()
|
||||
let reversed = |chain: &[mt::Edge]| chain.iter().rev().map(|e| e.inverse()).collect::<Vec<_>>();
|
||||
let mut edges_out: Vec<mt::Edge> = Vec::new();
|
||||
if f1_forward {
|
||||
edges_out.extend(c0.iter().cloned());
|
||||
edges_out.push(lb.clone());
|
||||
edges_out.extend(reversed(c1));
|
||||
edges_out.push(la.inverse());
|
||||
} else {
|
||||
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
|
||||
};
|
||||
edges_out.push(la.clone());
|
||||
edges_out.extend(c1.iter().cloned());
|
||||
edges_out.push(lb.inverse());
|
||||
edges_out.extend(reversed(c0));
|
||||
}
|
||||
let wire: mt::Wire = edges_out.into();
|
||||
let blend_face = match shape {
|
||||
Shape::Flat => builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("chamfer face: {e}")))?,
|
||||
Shape::Round => {
|
||||
let swept: mt::Face = builder::tsweep(&c0, v3(p1 - p0));
|
||||
// The cylinder, from a cross-section before the first end to one past the second, so
|
||||
// it holds the end sections wherever the end planes slide them.
|
||||
let reach = ends
|
||||
.iter()
|
||||
.flat_map(|e| e.planes.iter().map(|(_, n)| (s + r) * (1.0 - n.dot(d).powi(2)).max(0.0).sqrt() / n.dot(d).abs()))
|
||||
.fold(0.0, f64::max)
|
||||
+ r;
|
||||
let start = p0 - d * reach;
|
||||
let (sa, sb) = (start + t1 * s, start + t2 * s);
|
||||
let c = centre(start);
|
||||
let mid = c + ((sa - c) + (sb - c)).normalized().unwrap_or(-n1) * r;
|
||||
let base = builder::circle_arc(&builder::vertex(p3(sa)), &builder::vertex(p3(sb)), p3(mid));
|
||||
let swept: mt::Face = builder::tsweep(&base, v3(p1 - p0 + d * (2.0 * reach)));
|
||||
let mut surf = swept.oriented_surface();
|
||||
// Outward normal at the arc middle: away from the centre on a convex edge (the
|
||||
// centre is in the material), toward it on a concave one.
|
||||
let (c, mid) = (centre(p0), arc_mid(p0));
|
||||
let outward = (mid - c) * (-side);
|
||||
use mt::{ParametricSurface3D, SearchParameter};
|
||||
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
|
||||
@@ -1113,8 +1203,16 @@ mod tests {
|
||||
assert!(result.solid.vertex_iter().any(|v| vtx(&v).dist(point) < 1e-8));
|
||||
}
|
||||
assert!(blend(&body, &pick, 25.0, Shape::Flat, "chamfer").is_err());
|
||||
// Oblique fillet caps need elliptical trims, which this local path does not build.
|
||||
assert!(blend(&body, &pick, 2.0, Shape::Round, "fillet").is_err());
|
||||
// A fillet ends in each sloped end plane along an ellipse: the removed section's
|
||||
// length is taken at its centroid, c from the edge.
|
||||
let rounded = blend(&body, &pick, 2.0, Shape::Round, "fillet").unwrap();
|
||||
let (area, c) =
|
||||
((1.0 - std::f64::consts::PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * std::f64::consts::PI) / (3.0 * (4.0 - std::f64::consts::PI)));
|
||||
let removed = area * (40.0 - 2.0 * slope * c);
|
||||
let actual = crate::measure(&body).unwrap().volume - crate::measure(&rounded).unwrap().volume;
|
||||
// `measure` reads a round about 6e-4 heavy, square ends too.
|
||||
assert!((actual - removed).abs() < 1e-3 * removed, "{actual} vs {removed}");
|
||||
assert!(rounded.validity().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ mod sweep_path;
|
||||
#[cfg(test)]
|
||||
mod thread_tests;
|
||||
mod topo;
|
||||
mod tspline;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet};
|
||||
pub use blend_corner::fillet_radii;
|
||||
@@ -96,6 +97,7 @@ pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicke
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
|
||||
@@ -97,6 +97,22 @@ fn seg_of(e: &mt::Edge, tol: f64) -> Option<Seg> {
|
||||
Some(Seg::Arc { a, c: centre, axis, angle, mid: m })
|
||||
}
|
||||
|
||||
/// Whether an edge is a straight segment: a line, or a curve whose points all lie on the chord
|
||||
/// (the contact lines of a rolling-ball blend between parallel walls are such curves).
|
||||
fn is_straight(e: &mt::Edge, tol: f64) -> bool {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
if matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return true;
|
||||
}
|
||||
let (a, b) = (from_p3(e.absolute_front().point()), from_p3(e.absolute_back().point()));
|
||||
if a.dist(b) < tol {
|
||||
return false;
|
||||
}
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
(0..=16).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 16.0)).dist_to_segment(a, b) < tol)
|
||||
}
|
||||
|
||||
/// `p` turned about the line (c, axis) by `ang`.
|
||||
fn turn(p: Vec3, c: Vec3, axis: Vec3, ang: f64) -> Vec3 {
|
||||
let k = axis.normalized().unwrap_or(Vec3::Z);
|
||||
@@ -518,7 +534,7 @@ fn build(
|
||||
}
|
||||
let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone());
|
||||
let other = if &fa == v { &fb } else { &fa };
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
if !is_straight(&e, (size * 1e-5).max(1e-6)) {
|
||||
return Err(unsupported("the wall seams must be straight"));
|
||||
}
|
||||
let face_side = on_face(&e);
|
||||
|
||||
@@ -357,7 +357,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
};
|
||||
match &r {
|
||||
Some(body) => {
|
||||
let v = volume(body);
|
||||
// Measured as the operands were: a coarser mesh of a plate with many holes
|
||||
// overstates its volume by more than the slack.
|
||||
let v = match &meshes {
|
||||
(Some(_), Some(_)) => body.tessellate_with(tol_m, true).map(|m| m.measure().volume).unwrap_or(f64::NAN),
|
||||
_ => volume(body),
|
||||
};
|
||||
let bounds = match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
|
||||
@@ -737,9 +737,47 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
|
||||
|
||||
/// Ray parity against analytic lines/arcs. Fragment classification must not depend on a
|
||||
/// display chord tolerance: a tiny tool can sit between a circular boundary and its chords.
|
||||
/// Loops are closed only within the chaining tolerance (an arrangement's own output joins its
|
||||
/// pieces within `10 · tol`), so a gap where one line ends and the next begins is closed by a
|
||||
/// straight connector: a ray through the gap would otherwise slip out of the loop without a
|
||||
/// crossing and flip the parity of points level with the corner. Arcs decide their own ends
|
||||
/// within an angular tolerance (below), so joints with an arc keep that rule.
|
||||
fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
|
||||
// A ray through a vertex or grazing an arc's top or bottom is decided by the half-open
|
||||
// rule, which needs both curves meeting there to agree on that point exactly. Arcs read
|
||||
// back from a solid (a patterned hole's two halves) can disagree by a few ulps, so one half
|
||||
// counts the touch and the other misses it. Move the ray off such points: a shift far below
|
||||
// any modelling tolerance cannot change the answer for a point that is not on the loop.
|
||||
let scale = 1.0 + p.x.abs().max(p.y.abs());
|
||||
// A point on the loop keeps the half-open answer.
|
||||
if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) {
|
||||
return inside_loop_ray(lp, p);
|
||||
}
|
||||
let grazes = |q: Vec2| {
|
||||
let near = |y: f64| (y - q.y).abs() < scale * 1e-9;
|
||||
lp.segs.iter().any(|s| {
|
||||
near(s.start().y)
|
||||
|| match *s {
|
||||
Seg2::Arc { center, radius, .. } => near(center.y + radius) || near(center.y - radius),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
};
|
||||
let ray = [0.0, 1.0, -1.0, 2.0, -2.0, 3.0, -3.0].into_iter().map(|k| Vec2::new(p.x, p.y + k * scale * 1e-7)).find(|q| !grazes(*q)).unwrap_or(p);
|
||||
inside_loop_ray(lp, ray)
|
||||
}
|
||||
|
||||
/// [`inside_loop`] with the ray along +x from `p` taken as given.
|
||||
fn inside_loop_ray(lp: &Loop2, p: Vec2) -> bool {
|
||||
let mut odd = false;
|
||||
for seg in &lp.segs {
|
||||
let nexts = lp.segs.iter().skip(1).chain(lp.segs.first());
|
||||
for (seg, next) in lp.segs.iter().zip(nexts) {
|
||||
if let (Seg2::Line { b: a, .. }, Seg2::Line { a: b, .. }) = (*seg, *next)
|
||||
&& (a.y > p.y) != (b.y > p.y)
|
||||
&& a.x + (p.y - a.y) * (b.x - a.x) / (b.y - a.y) > p.x
|
||||
{
|
||||
odd = !odd;
|
||||
}
|
||||
match *seg {
|
||||
Seg2::Line { a, b } => {
|
||||
if (a.y > p.y) != (b.y > p.y) {
|
||||
@@ -2147,6 +2185,56 @@ mod planar_tests {
|
||||
assert!(!inside_loop(&lp, Vec2::new(-5.0, 0.0)));
|
||||
assert!(!inside_loop(&lp, Vec2::new(-5.0, -10.0)));
|
||||
}
|
||||
// #307: a circle read back as two halves whose centres disagree by a few ulps, split
|
||||
// at its bottom. A ray grazing that bottom must not count one half's touch only.
|
||||
let halves = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Arc { center: Vec2::new(0.0, 25.0), radius: 5.0, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
|
||||
Seg2::Arc {
|
||||
center: Vec2::new(2.9e-15, 25.000000000000004),
|
||||
radius: 4.99999999999999,
|
||||
start: std::f64::consts::FRAC_PI_2,
|
||||
sweep: std::f64::consts::PI,
|
||||
},
|
||||
],
|
||||
};
|
||||
for lp in [halves.clone(), halves.reversed()] {
|
||||
for x in [-30.0, -5.0, -0.1] {
|
||||
assert!(!inside_loop(&lp, Vec2::new(x, 20.0)), "{x}");
|
||||
assert!(!inside_loop(&lp, Vec2::new(x, 30.0)), "{x}");
|
||||
}
|
||||
assert!(inside_loop(&lp, Vec2::new(0.0, 25.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Loops are closed only within the chaining tolerance. A ray level with one end of such a
|
||||
/// gap slipped through it without a crossing, so a point beside the loop counted as inside:
|
||||
/// a sheet shrunk by a parameter change (sketch solver residuals of 1e-13 mm) lost a bend
|
||||
/// zone that way and its unfold failed.
|
||||
#[test]
|
||||
fn ray_parity_closes_the_gaps_chaining_leaves() {
|
||||
let gap = 1e-13;
|
||||
let square = Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Line { a: Vec2::new(0.0, 0.0), b: Vec2::new(10.0, 0.0) },
|
||||
Seg2::Line { a: Vec2::new(10.0, 0.0), b: Vec2::new(10.0, 10.0) },
|
||||
Seg2::Line { a: Vec2::new(10.0, 10.0), b: Vec2::new(0.0, 10.0) },
|
||||
Seg2::Line { a: Vec2::new(0.0, 10.0), b: Vec2::new(0.0, 5.0 + gap) },
|
||||
Seg2::Line { a: Vec2::new(0.0, 5.0), b: Vec2::new(0.0, 0.0) },
|
||||
],
|
||||
};
|
||||
for lp in [square.clone(), square.reversed()] {
|
||||
assert!(!inside_loop(&lp, Vec2::new(-5.0, 5.0)));
|
||||
assert!(inside_loop(&lp, Vec2::new(5.0, 5.0)));
|
||||
assert!(!inside_loop(&lp, Vec2::new(15.0, 5.0)));
|
||||
}
|
||||
// A neighbour with an edge level with the gap joins the square instead of being
|
||||
// dropped as if it lay inside it (which left the outline open: no result at all).
|
||||
let a = Region2 { outer: square, holes: vec![] };
|
||||
let b = rectangle(Vec2::new(-4.0, 2.0), Vec2::new(0.0, 5.0));
|
||||
let union = planar_regions_boolean(&[a], &[b], BoolOp::Union, 1e-7).unwrap();
|
||||
assert_eq!(union.len(), 1);
|
||||
assert!((union[0].area().abs() - 112.0).abs() < 1e-9, "{}", union[0].area());
|
||||
}
|
||||
#[test]
|
||||
fn tiny_tool_between_circle_and_display_chord_is_not_lost() {
|
||||
|
||||
@@ -22,7 +22,7 @@ impl Body {
|
||||
}
|
||||
|
||||
/// A body of faces as they are (no orientation fix: a surface has no inside).
|
||||
fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
pub(crate) fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
surface_of(mt::Shell::from(faces))
|
||||
}
|
||||
|
||||
|
||||
@@ -176,9 +176,87 @@ fn fillets_on_several_edges_and_angles() {
|
||||
let cut = 24000.0 - measure(&two).unwrap().volume;
|
||||
let a = 4.0 * (1.0 - PI / 4.0);
|
||||
assert!(cut > a * (20.0 + 30.0 - 2.0) && cut < a * 50.0, "{cut}");
|
||||
// Concave edge: not supported.
|
||||
// Concave edge where a post stands on the plate: the round adds material, its end caps
|
||||
// extending the post's end faces down onto the plate.
|
||||
let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
|
||||
let f = fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).unwrap();
|
||||
let added = measure(&f).unwrap().volume - measure(&u).unwrap().volume;
|
||||
assert!(rel(added, (1.0 - PI / 4.0) * 10.0) < 1e-3, "{added}");
|
||||
assert!(f.validity().is_empty());
|
||||
assert_eq!(f.lumps().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_ends_in_an_end_face_at_an_angle() {
|
||||
// #215: the round ends in the end face's plane along an ellipse (its section) when that face
|
||||
// is not square to the edge. A prism on a trapezoid (0,0) (40,0) (30,20) (0,20), 10 high: the
|
||||
// top edge along y = 0 ends at x = 0 (square) and in the face x = 40 - y/2 (at an angle).
|
||||
let r: f64 = 2.0;
|
||||
let (area, c) = ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
|
||||
let trapezoid = |far: f64| {
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(far, 20.0), Vec2::new(0.0, 20.0)];
|
||||
extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], 0.0, 10.0).unwrap().pop().unwrap()
|
||||
};
|
||||
// The removed section's length at depth y into the top face is 40 + (far - 40) y / 20; its
|
||||
// mean over the section is taken at the section's centroid (c from the edge).
|
||||
for far in [30.0, 50.0] {
|
||||
let b = trapezoid(far);
|
||||
let v0 = measure(&b).unwrap().volume;
|
||||
let f = fillet(&b, &[Vec3::new(20.0, 0.0, 10.0)], r).unwrap();
|
||||
let want = v0 - area * (40.0 + (far - 40.0) * c / 20.0);
|
||||
let m = measure(&f).unwrap();
|
||||
assert!(rel(m.volume, want) < 2e-4, "far {far}: {} vs {want}", m.volume);
|
||||
assert_eq!(f.lumps().unwrap().len(), 1);
|
||||
assert!(f.validity().is_empty(), "far {far}: {:?}", f.validity());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_ending_across_two_faces() {
|
||||
// #215: a plate's outline chamfered, then the mitre where two chamfers meet at a corner
|
||||
// rounded. The mitre runs from the top face (ending in it at an angle) down to the corner
|
||||
// where two side faces meet; the round ends across both, cutting back the vertical edge.
|
||||
let (r, k) = (2.0, 5.0);
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(100.0, 50.0, 20.0)).unwrap();
|
||||
let outline = [Vec3::new(50.0, 0.0, 20.0), Vec3::new(100.0, 25.0, 20.0), Vec3::new(50.0, 50.0, 20.0), Vec3::new(0.0, 25.0, 20.0)];
|
||||
let c = chamfer(&b, &outline, k).unwrap();
|
||||
let f = fillet(&c, &[Vec3::new(k / 2.0, k / 2.0, 20.0 - k / 2.0)], r).unwrap();
|
||||
assert!(f.validity().is_empty(), "{:?}", f.validity());
|
||||
assert_eq!(f.lumps().unwrap().len(), 1);
|
||||
// The removed material, by definition: inside the plate, between the two chamfers and the
|
||||
// round, i.e. outside the round's cylinder and on the corner's side of both lines where it
|
||||
// touches the chamfers (each at the setback s = r cot(φ/2) from the mitre, φ = 120° between
|
||||
// the chamfers). Sampled at pseudo-random points (a regular grid aliases with the region's
|
||||
// thin slivers along the chamfers): about 1.89 mm³, standard error about 0.02.
|
||||
let d = Vec3::new(1.0, 1.0, 1.0).normalized().unwrap();
|
||||
let (n1, n2) = (Vec3::new(0.0, -1.0, 1.0).normalized().unwrap(), Vec3::new(-1.0, 0.0, 1.0).normalized().unwrap());
|
||||
// In each chamfer, square to the mitre and away from the other chamfer.
|
||||
let into = |n: Vec3, other: Vec3| {
|
||||
let t = n.cross(d).normalized().unwrap();
|
||||
if t.dot(other) < 0.0 { t } else { -t }
|
||||
};
|
||||
let (t1, t2) = (into(n1, n2), into(n2, n1));
|
||||
let phi = (-n1.dot(n2)).acos();
|
||||
let s = r / (phi / 2.0).tan();
|
||||
let corner = Vec3::new(0.0, 0.0, 20.0 - k);
|
||||
let axis_at = corner + t1 * s - n1 * r;
|
||||
let removed_at = |q: Vec3| {
|
||||
let u = q - axis_at;
|
||||
q.z - q.x <= 15.0 && q.z - q.y <= 15.0 && (q - corner).dot(t1) < s && (q - corner).dot(t2) < s && (u - d * u.dot(d)).len() > r
|
||||
};
|
||||
let (lo, span) = (Vec3::new(0.0, 0.0, 20.0 - k - 2.0 * r), k + 2.0 * r);
|
||||
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
let mut unit = || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
(state >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let samples = 4_000_000;
|
||||
let hits = (0..samples).filter(|_| removed_at(lo + Vec3::new(unit(), unit(), unit()) * span)).count();
|
||||
let removed = span.powi(3) * hits as f64 / samples as f64;
|
||||
let lost = measure(&c).unwrap().volume - measure(&f).unwrap().volume;
|
||||
assert!((lost - removed).abs() < 0.07, "{lost} vs {removed}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1721,8 +1799,9 @@ fn loft_section_order_gives_the_same_body() {
|
||||
|
||||
#[test]
|
||||
fn tangent_holes_pinch_alike() {
|
||||
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. The ±25 wall is cut
|
||||
// in two at the pinch, the same way on both sides (as Fusion counts it).
|
||||
// Holes at ±15 and ±25 touch along a line (x = ±20); ±30 overlaps ±25. A tangent line
|
||||
// contact does not split a face (#307, as the reference kernel counts it): the ±25 wall is one
|
||||
// face on both sides, so 6 planes and 6 hole walls.
|
||||
let mut b = box_solid(Vec3::new(-50.0, -20.0, 0.0), Vec3::new(50.0, 20.0, 5.0)).unwrap();
|
||||
for x in [15.0, 25.0, 30.0, -15.0, -25.0, -30.0] {
|
||||
let plane = Plane::XY.offset(-1.0);
|
||||
@@ -1731,7 +1810,7 @@ fn tangent_holes_pinch_alike() {
|
||||
b = boolean(&b, &t, BoolOp::Cut).unwrap().unwrap();
|
||||
}
|
||||
let m = measure(&b).unwrap();
|
||||
assert_eq!(m.merged.faces, 14, "{:?}", m.merged);
|
||||
assert_eq!(m.merged.faces, 12, "{:?}", m.merged);
|
||||
}
|
||||
|
||||
/// A d10 tube bent at R5: the inside of the bend is a horn torus pinched to a point. One torus
|
||||
|
||||
@@ -239,7 +239,7 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
|
||||
let solid = &*b.solid;
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let surfs = classify_faces(b, mesh, nf, tol);
|
||||
let verts = face_vertices(mesh, nf);
|
||||
// Faces cut from one surface (an imported face split where it wraps around a closed
|
||||
// surface) carry the very same surface: the edge between them is a seam.
|
||||
@@ -312,7 +312,7 @@ fn fit_sphere(pts: &[Vec3]) -> Option<(Vec3, f64)> {
|
||||
(r2 > 0.0 && r2.is_finite()).then(|| (c + m, r2.sqrt()))
|
||||
}
|
||||
|
||||
fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
|
||||
fn classify_faces(b: &Body, mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
|
||||
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
@@ -351,7 +351,27 @@ fn classify_faces(mesh: &Mesh, nf: usize, tol: f64) -> Vec<Surf> {
|
||||
nv.retain(|_| it.next().copied().unwrap_or(true));
|
||||
}
|
||||
}
|
||||
(0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i)).collect()
|
||||
let fitted = (0..nf).map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i));
|
||||
// A sliver (a strip one triangle wide) has too few distinct samples to fit: its points
|
||||
// can fit a sphere or nothing. Where the fit finds no plane or cylinder, take the face's
|
||||
// exact surface when that is a plane, cylinder or sphere.
|
||||
let exact_tol = (b.size() * 1e-7).max(1e-9) * 100.0;
|
||||
let faces: Vec<_> = b.solid.face_iter().collect();
|
||||
fitted
|
||||
.enumerate()
|
||||
.map(|(i, fit)| match (fit, faces.get(i)) {
|
||||
(Surf::Sphere { .. } | Surf::Other(_), Some(face)) => match crate::offset::surf_of(face, exact_tol) {
|
||||
Some(crate::offset::Surf::Plane { n, d }) => Surf::Plane { n, d },
|
||||
Some(crate::offset::Surf::Cylinder { o, a, r, .. }) => {
|
||||
let axis = if a.x + a.y * 1e-3 + a.z * 1e-6 < 0.0 { -a } else { a };
|
||||
Surf::Cylinder { axis, p: o, r }
|
||||
}
|
||||
Some(crate::offset::Surf::Sphere { c, r, .. }) => Surf::Sphere { c, r },
|
||||
_ => fit,
|
||||
},
|
||||
_ => fit,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`.
|
||||
@@ -373,7 +393,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let solid = &*b.solid;
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let surfs = classify_faces(b, mesh, nf, tol);
|
||||
let verts = face_vertices(mesh, nf);
|
||||
// Zero-area faces (a revolved profile edge lying on the axis) don't count.
|
||||
let mut area = vec![0.0f64; nf];
|
||||
@@ -472,6 +492,24 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Two tangent holes touch along a line that both carry: there four faces meet, two on
|
||||
// each hole's surface. Each surface's two pieces are one face, as for any other edge.
|
||||
for (ei, faces) in &edge_faces {
|
||||
if faces.len() <= 2 || faces.len() > 8 || (keep_split && poly(*ei).is_some_and(|pl| crate::heal::on_split_line(pl, b_keep))) {
|
||||
continue;
|
||||
}
|
||||
for (i, &a) in faces.iter().enumerate() {
|
||||
for &b in faces.iter().skip(i + 1) {
|
||||
let analytic = !matches!(surfs.get(a), Some(Surf::Other(_)) | None);
|
||||
if analytic && same_surface(&surfs, &verts, poly(*ei), a, b, tol * 10.0) {
|
||||
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
|
||||
if let Some(s) = fp.get_mut(ra) {
|
||||
*s = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut groups: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut face_types: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in 0..nf {
|
||||
@@ -604,7 +642,7 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option<CylinderFace> {
|
||||
}
|
||||
let nf = b.face_count();
|
||||
let tol = (size * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(&mesh, nf, tol);
|
||||
let surfs = classify_faces(b, &mesh, nf, tol);
|
||||
let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None };
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
//! T-spline sheets: a T-spline surface (`solvecraft_geom::tspline`) as a surface body of NURBS
|
||||
//! faces. A T-mesh without T-junctions becomes one clamped tensor face; otherwise each Bézier
|
||||
//! element is a face, and the faces share their edges and vertices — a side that meets two
|
||||
//! neighbours is split at the T-point between them — so the sheet is one sewn open shell.
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use solvecraft_geom::bspline::{BezierPatch, HPoint, NurbsSurface, SplineError, bezier_segment};
|
||||
use solvecraft_geom::tspline::TSpline;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// A T-spline as a surface body (an open shell of NURBS faces whose normals follow
|
||||
/// `∂S/∂s × ∂S/∂t`).
|
||||
pub fn tspline_sheet(ts: &TSpline) -> Result<Body> {
|
||||
let faces = if ts.mesh().is_regular() {
|
||||
let n = ts.to_nurbs().and_then(|n| n.clamped()).map_err(spline_err)?;
|
||||
guard("t-spline sheet", || tensor_face(&n).map(|f| vec![f]))?
|
||||
} else {
|
||||
let patches = ts.bezier_elements().map_err(spline_err)?;
|
||||
guard("t-spline sheet", || patch_faces(&patches))?
|
||||
};
|
||||
Ok(crate::surfaces::surface_body(faces))
|
||||
}
|
||||
|
||||
fn spline_err(e: SplineError) -> KernelError {
|
||||
KernelError::Invalid(format!("t-spline: {e}"))
|
||||
}
|
||||
|
||||
fn fail(m: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Failed(format!("t-spline sheet: {m}"))
|
||||
}
|
||||
|
||||
fn h4(h: HPoint) -> mt::Vector4 {
|
||||
mt::Vector4::new(h.x, h.y, h.z, h.w)
|
||||
}
|
||||
|
||||
fn vertex(h: HPoint) -> Result<mt::Vertex> {
|
||||
let p = h.project().ok_or_else(|| fail("a corner has no position"))?;
|
||||
Ok(mt::builder::vertex(crate::body::p3(p)))
|
||||
}
|
||||
|
||||
fn nurbs_curve(knots: mt::KnotVec, ctrl: &[HPoint]) -> Result<mt::Curve> {
|
||||
let c = mt::BSplineCurve::try_new(knots, ctrl.iter().copied().map(h4).collect()).map_err(fail)?;
|
||||
Ok(mt::Curve::NurbsCurve(mt::NurbsCurve::new(c)))
|
||||
}
|
||||
|
||||
fn surface(knots: (mt::KnotVec, mt::KnotVec), rows: Vec<Vec<mt::Vector4>>) -> Result<mt::Surface> {
|
||||
let s = mt::BSplineSurface::try_new(knots, rows).map_err(fail)?;
|
||||
Ok(mt::Surface::NurbsSurface(mt::NurbsSurface::new(s)))
|
||||
}
|
||||
|
||||
/// One face from a clamped tensor surface, bounded by its four boundary curves.
|
||||
fn tensor_face(n: &NurbsSurface) -> Result<mt::Face> {
|
||||
let [nu, nv] = n.count();
|
||||
let [ku, kv] = n.knots();
|
||||
let (ku, kv) = (mt::KnotVec::from(ku.clone()), mt::KnotVec::from(kv.clone()));
|
||||
let corners = [n.point(0, 0), n.point(nu - 1, 0), n.point(nu - 1, nv - 1), n.point(0, nv - 1)];
|
||||
let v: Vec<mt::Vertex> = corners.into_iter().map(vertex).collect::<Result<_>>()?;
|
||||
let row = |j: usize| -> Vec<HPoint> { (0..nu).map(|i| n.point(i, j)).collect() };
|
||||
let column = |i: usize| -> Vec<HPoint> { (0..nv).map(|j| n.point(i, j)).collect() };
|
||||
let (Some(v0), Some(v1), Some(v2), Some(v3)) = (v.first(), v.get(1), v.get(2), v.get(3)) else { return Err(fail("corners")) };
|
||||
let edge = |a: &mt::Vertex, b: &mt::Vertex, c: mt::Curve| mt::Edge::try_new(a, b, c).map_err(fail);
|
||||
let bottom = edge(v0, v1, nurbs_curve(ku.clone(), &row(0))?)?;
|
||||
let right = edge(v1, v2, nurbs_curve(kv.clone(), &column(nu - 1))?)?;
|
||||
let top = edge(v3, v2, nurbs_curve(ku.clone(), &row(nv - 1))?)?;
|
||||
let left = edge(v0, v3, nurbs_curve(kv.clone(), &column(0))?)?;
|
||||
let wire: mt::Wire = vec![bottom, right, top.inverse(), left.inverse()].into();
|
||||
let rows: Vec<Vec<mt::Vector4>> = (0..nu).map(|i| (0..nv).map(|j| h4(n.point(i, j))).collect()).collect();
|
||||
mt::Face::try_new(vec![wire], surface((ku, kv), rows)?).map_err(fail)
|
||||
}
|
||||
|
||||
/// A parameter point, exact (knot values are shared, so equal points have equal bits).
|
||||
type Key = (u64, u64);
|
||||
|
||||
fn key(s: f64, t: f64) -> Key {
|
||||
((s + 0.0).to_bits(), (t + 0.0).to_bits())
|
||||
}
|
||||
|
||||
/// One face per Bézier patch, sharing vertices and edges; sides are split where a
|
||||
/// neighbour's corner lands on them.
|
||||
fn patch_faces(patches: &[BezierPatch]) -> Result<Vec<mt::Face>> {
|
||||
// Corner parameters on each line of constant t (their s values) and of constant s.
|
||||
let mut on_t: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
|
||||
let mut on_s: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
|
||||
for p in patches {
|
||||
for s in p.s {
|
||||
for t in p.t {
|
||||
let (ks, kt) = key(s, t);
|
||||
on_t.entry(kt).or_default().insert(ks);
|
||||
on_s.entry(ks).or_default().insert(kt);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut verts: HashMap<Key, mt::Vertex> = HashMap::new();
|
||||
let mut edges: HashMap<(Key, Key), mt::Edge> = HashMap::new();
|
||||
let mut faces = Vec::with_capacity(patches.len());
|
||||
for p in patches {
|
||||
let mut wire: Vec<mt::Edge> = Vec::new();
|
||||
for side in 0..4 {
|
||||
// Side 0: t = t₀, 1: s = s₁, 2: t = t₁, 3: s = s₀; each runs with its parameter
|
||||
// rising, sides 2 and 3 are walked backwards.
|
||||
let (along_s, fixed, [lo, hi]) = match side {
|
||||
0 => (true, p.t[0], p.s),
|
||||
1 => (false, p.s[1], p.t),
|
||||
2 => (true, p.t[1], p.s),
|
||||
_ => (false, p.s[0], p.t),
|
||||
};
|
||||
let line = if along_s { on_t.get(&key(0.0, fixed).1) } else { on_s.get(&key(fixed, 0.0).0) };
|
||||
let mut breaks: Vec<f64> = line.into_iter().flatten().map(|&b| f64::from_bits(b)).filter(|&x| x > lo && x < hi).collect();
|
||||
breaks.insert(0, lo);
|
||||
breaks.push(hi);
|
||||
let ctrl = p.side(side);
|
||||
let at = |x: f64| if along_s { key(x, fixed) } else { key(fixed, x) };
|
||||
let mut pieces = Vec::with_capacity(breaks.len());
|
||||
for w in breaks.windows(2) {
|
||||
let &[x0, x1] = w else { continue };
|
||||
let (ka, kb) = (at(x0), at(x1));
|
||||
let e = if let Some(e) = edges.get(&(ka, kb)) {
|
||||
e.clone()
|
||||
} else if let Some(e) = edges.get(&(kb, ka)) {
|
||||
e.inverse()
|
||||
} else {
|
||||
let seg = bezier_segment(&ctrl, (x0 - lo) / (hi - lo), (x1 - lo) / (hi - lo)).map_err(spline_err)?;
|
||||
let (Some(&first), Some(&last)) = (seg.first(), seg.last()) else { return Err(fail("an empty side")) };
|
||||
let mut corner = |k: Key, h: HPoint| -> Result<mt::Vertex> {
|
||||
Ok(match verts.entry(k) {
|
||||
Entry::Occupied(e) => e.get().clone(),
|
||||
Entry::Vacant(e) => e.insert(vertex(h)?).clone(),
|
||||
})
|
||||
};
|
||||
let (va, vb) = (corner(ka, first)?, corner(kb, last)?);
|
||||
let curve = nurbs_curve(mt::KnotVec::bezier_knot(seg.len() - 1), &seg)?;
|
||||
let e = mt::Edge::try_new(&va, &vb, curve).map_err(fail)?;
|
||||
edges.insert((ka, kb), e.clone());
|
||||
e
|
||||
};
|
||||
pieces.push(e);
|
||||
}
|
||||
if side >= 2 {
|
||||
pieces.reverse();
|
||||
pieces.iter_mut().for_each(|e| *e = e.inverse());
|
||||
}
|
||||
wire.extend(pieces);
|
||||
}
|
||||
let [ps, pt] = p.degree;
|
||||
let rows: Vec<Vec<mt::Vector4>> = (0..=ps).map(|i| (0..=pt).map(|j| h4(p.point(i, j))).collect()).collect();
|
||||
let surf = surface((mt::KnotVec::bezier_knot(ps), mt::KnotVec::bezier_knot(pt)), rows)?;
|
||||
faces.push(mt::Face::try_new(vec![wire.into()], surf).map_err(fail)?);
|
||||
}
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use solvecraft_geom::Vec3;
|
||||
use solvecraft_geom::bspline::NurbsSurface;
|
||||
use solvecraft_geom::tspline::{Cut, TSpline};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use super::tspline_sheet;
|
||||
use crate::fuzz::Rng;
|
||||
|
||||
/// A rational bicubic with uneven knots over a 10 × 8 mm area, bulging up to ~3 mm.
|
||||
fn surface(seed: u64) -> TSpline {
|
||||
let mut rng = Rng::new(seed);
|
||||
let ku = vec![0.0, 0.0, 0.0, 0.0, 0.2, 0.45, 0.5, 0.8, 1.0, 1.0, 1.0, 1.0];
|
||||
let kv = vec![0.0, 0.0, 0.0, 0.0, 0.3, 0.6, 1.0, 1.0, 1.0, 1.0];
|
||||
let (nu, nv) = (ku.len() - 4, kv.len() - 4);
|
||||
let mut pts = Vec::new();
|
||||
for j in 0..nv {
|
||||
for i in 0..nu {
|
||||
pts.push(Vec3::new(10.0 * i as f64 / (nu - 1) as f64, 8.0 * j as f64 / (nv - 1) as f64, 3.0 * rng.unit()));
|
||||
}
|
||||
}
|
||||
let w: Vec<f64> = (0..pts.len()).map(|_| 0.7 + 0.6 * rng.unit()).collect();
|
||||
TSpline::from_nurbs(&NurbsSurface::new([3, 3], [ku, kv], &pts, &w).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Distance from `p` to the T-spline: nearest of a sample grid, then Gauss–Newton.
|
||||
fn distance(ts: &TSpline, p: Vec3) -> f64 {
|
||||
let [ds, dt] = ts.domain();
|
||||
let n = 24;
|
||||
let mut best = (f64::MAX, 0.0, 0.0);
|
||||
for i in 0..=n {
|
||||
for j in 0..=n {
|
||||
let (s, t) = (ds[0] + (ds[1] - ds[0]) * i as f64 / n as f64, dt[0] + (dt[1] - dt[0]) * j as f64 / n as f64);
|
||||
let d = ts.eval(s, t).unwrap().dist(p);
|
||||
if d < best.0 {
|
||||
best = (d, s, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, mut s, mut t) = best;
|
||||
for _ in 0..60 {
|
||||
let [q, a, b] = ts.derivs(s, t).unwrap();
|
||||
let r = q - p;
|
||||
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
|
||||
let det = aa * bb - ab * ab;
|
||||
if det.abs() < 1e-30 {
|
||||
break;
|
||||
}
|
||||
let (ga, gb) = (a.dot(r), b.dot(r));
|
||||
s = (s - (bb * ga - ab * gb) / det).clamp(ds[0], ds[1]);
|
||||
t = (t - (aa * gb - ab * ga) / det).clamp(dt[0], dt[1]);
|
||||
}
|
||||
ts.eval(s, t).unwrap().dist(p)
|
||||
}
|
||||
|
||||
fn check_sheet(ts: &TSpline, faces: usize) {
|
||||
let body = tspline_sheet(ts).unwrap();
|
||||
assert!(body.is_surface());
|
||||
assert_eq!(body.shell_count(), 1);
|
||||
assert_eq!(body.face_count(), faces);
|
||||
let shell = &body.solid.boundaries()[0];
|
||||
assert_eq!(shell.shell_condition(), mt::ShellCondition::Oriented, "neighbours share edges, run opposite ways");
|
||||
let euler = body.vertex_count() as i64 - body.edge_count() as i64 + body.face_count() as i64;
|
||||
assert_eq!(euler, 1, "one sewn sheet (a disc)");
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
assert!(!mesh.triangles.is_empty());
|
||||
// Mesh points lie on the surface (not merely within the tessellation tolerance).
|
||||
let step = (mesh.positions.len() / 300).max(1);
|
||||
let worst = mesh.positions.iter().step_by(step).map(|p| distance(ts, *p)).fold(0.0, f64::max);
|
||||
eprintln!("t-spline sheet: {faces} faces, {} mesh points, farthest checked {worst:e} mm from the surface", mesh.positions.len());
|
||||
assert!(worst < 1e-6, "a mesh point is {worst} mm off the T-spline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_tspline_becomes_one_nurbs_face() {
|
||||
check_sheet(&surface(1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tspline_with_t_junctions_becomes_sewn_bezier_faces() {
|
||||
let ts = surface(2).refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }, Cut::T { t: 0.45, s0: 0.2, s1: 0.45 }]).unwrap();
|
||||
assert!(!ts.mesh().is_regular());
|
||||
let n = ts.bezier_elements().unwrap().len();
|
||||
check_sheet(&ts, n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_tsplines_never_panic() {
|
||||
let base = surface(3);
|
||||
let n = base.mesh().vertices().len();
|
||||
let mesh = base.mesh().clone();
|
||||
// Every control point at one place, enormous coordinates, a needle-thin patch.
|
||||
let cases: Vec<Vec<Vec3>> = vec![
|
||||
vec![Vec3::new(1.0, 2.0, 3.0); n],
|
||||
(0..n).map(|k| Vec3::new(1e300, -1e300, k as f64 * 1e299)).collect(),
|
||||
(0..n).map(|k| Vec3::new(k as f64 * 1e-12, 0.0, 0.0)).collect(),
|
||||
];
|
||||
for pts in cases {
|
||||
let Ok(ts) = TSpline::new(mesh.clone(), [3, 3], &pts, &vec![1.0; n]) else { continue };
|
||||
if let Ok(body) = tspline_sheet(&ts) {
|
||||
let _ = body.tessellate(0.01);
|
||||
}
|
||||
if let Ok(r) = ts.refine(&[Cut::S { s: 0.33, t0: 0.3, t1: 0.6 }])
|
||||
&& let Ok(body) = tspline_sheet(&r)
|
||||
{
|
||||
let _ = body.tessellate(0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Solve times of an n×n grid (origin corner fixed, every row and column edge horizontal or
|
||||
//! vertical with a distance), started at the solution and ±1 mm off it, and of one held drag
|
||||
//! frame on a strip of rectangles. Run with
|
||||
//! `cargo run --release -p solvecraft-sketch --example solve_bench`; results go in docs/perf.md.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
use solvecraft_sketch::{Analysis, Budget, ConstraintKind, Hold, Sketch, SolveReport, solve, solve_with};
|
||||
|
||||
/// A small deterministic generator for the start offsets.
|
||||
struct Lcg(u64);
|
||||
|
||||
impl Lcg {
|
||||
/// In [-1, 1).
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((self.0 >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn grid(n: usize, jitter: f64) -> Result<Sketch, Box<dyn std::error::Error>> {
|
||||
let mut sk = Sketch::new();
|
||||
let mut rnd = Lcg(12345);
|
||||
let mut id = vec![vec![0; n]; n];
|
||||
for (r, row) in id.iter_mut().enumerate() {
|
||||
for (c, slot) in row.iter_mut().enumerate() {
|
||||
let j = if r == 0 && c == 0 { Vec2::ZERO } else { Vec2::new(rnd.next(), rnd.next()) * jitter };
|
||||
*slot = sk.add_point(Vec2::new(c as f64 * 10.0, r as f64 * 10.0) + j, None)?;
|
||||
}
|
||||
}
|
||||
sk.add_constraint(ConstraintKind::Fix { p: id[0][0] }, None)?;
|
||||
for r in 0..n {
|
||||
for c in 0..n {
|
||||
for (dr, dc, horizontal) in [(0, 1, true), (1, 0, false)] {
|
||||
let (Some(&p), Some(&q)) = (id.get(r).and_then(|x| x.get(c)), id.get(r + dr).and_then(|x| x.get(c + dc))) else { continue };
|
||||
let l = sk.add_line_pts(p, q, None)?;
|
||||
sk.add_constraint(if horizontal { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }, None)?;
|
||||
sk.add_constraint(ConstraintKind::Distance { p, q, value: 10.0 }, None)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sk)
|
||||
}
|
||||
|
||||
/// `n` rectangles side by side sharing their vertical edges: horizontal and vertical sides, every
|
||||
/// width dimensioned, the first height too. Returns the sketch and its first corner.
|
||||
fn strip(n: usize) -> Result<(Sketch, usize), Box<dyn std::error::Error>> {
|
||||
let mut sk = Sketch::new();
|
||||
let bottom: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 10.0), None)).collect::<Result<_, _>>()?;
|
||||
let top: Vec<usize> = (0..=n).map(|i| sk.add_point(Vec2::new(10.0 + i as f64 * 20.0, 25.0), None)).collect::<Result<_, _>>()?;
|
||||
for i in 0..=n {
|
||||
let l = sk.add_line_pts(bottom[i], top[i], None)?;
|
||||
sk.add_constraint(ConstraintKind::Vertical { l }, None)?;
|
||||
if i == 0 {
|
||||
sk.add_constraint(ConstraintKind::Length { l, value: 15.0 }, None)?;
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
let b = sk.add_line_pts(bottom[i], bottom[i + 1], None)?;
|
||||
let t = sk.add_line_pts(top[i], top[i + 1], None)?;
|
||||
sk.add_constraint(ConstraintKind::Horizontal { l: b }, None)?;
|
||||
sk.add_constraint(ConstraintKind::Horizontal { l: t }, None)?;
|
||||
sk.add_constraint(ConstraintKind::Length { l: b, value: 20.0 }, None)?;
|
||||
}
|
||||
Ok((sk, bottom[0]))
|
||||
}
|
||||
|
||||
/// Best of `reps` solves of a copy of `sk` (ms) and the last report.
|
||||
fn time(sk: &Sketch, reps: usize, f: impl Fn(&mut Sketch) -> SolveReport) -> (f64, SolveReport) {
|
||||
let mut best = f64::MAX;
|
||||
let mut rep = None;
|
||||
for _ in 0..reps {
|
||||
let mut k = sk.clone();
|
||||
let t = Instant::now();
|
||||
let r = f(&mut k);
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1e3);
|
||||
rep = Some(r);
|
||||
}
|
||||
(best, rep.unwrap_or_else(|| solve(&mut sk.clone())))
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("| grid | points | start | ms | status | dof | iterations |");
|
||||
println!("|---|---|---|---|---|---|---|");
|
||||
for n in [10usize, 15, 20] {
|
||||
for jitter in [0.0, 1.0] {
|
||||
let sk = grid(n, jitter)?;
|
||||
let (ms, rep) = time(&sk, 5, solve);
|
||||
let start = if jitter == 0.0 { "at solution" } else { "±1 mm" };
|
||||
println!("| {n}×{n} | {} | {start} | {ms:.2} | {:?} | {} | {} |", n * n, rep.status, rep.dof, rep.iterations);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("| strip | points | held drag frame ms | full solve ms |");
|
||||
println!("|---|---|---|---|");
|
||||
for n in [30usize, 100] {
|
||||
let (mut sk, corner) = strip(n)?;
|
||||
if let Some(p) = sk.points.get_mut(corner) {
|
||||
p.pos -= Vec2::new(0.5, 0.0);
|
||||
}
|
||||
let hold = Hold { points: vec![corner], radii: vec![] };
|
||||
let (frame, rep) = time(&sk, 5, |k| solve_with(k, &hold, None, Analysis::StatusOnly, Some(&mut Budget::frame())));
|
||||
let (full, _) = time(&sk, 5, solve);
|
||||
println!("| {n} rectangles | {} | {frame:.2} ({:?}) | {full:.2} |", sk.points.len(), rep.status);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! Constructive seed for the solver.
|
||||
//!
|
||||
//! Before Levenberg–Marquardt runs, every point the constraints pin down relative to the given
|
||||
//! points is placed exactly (Owen 1991, §3 and §5; Fudos and Hoffmann 1997), in two
|
||||
//! interleaved steps repeated until neither places anything:
|
||||
//!
|
||||
//! - **propagation**: a point with two loci anchored on already placed points goes to their
|
||||
//! intersection;
|
||||
//! - **decomposition** ([`crate::decompose`]): points no point-by-point order reaches but that
|
||||
//! are rigid relative to the placed ones (Owen's articulation-pair reduction) are placed by
|
||||
//! cluster merging, with the placed points as the ground.
|
||||
//!
|
||||
//! Then a rigid cluster that is free or pinned at a single placed point (nothing decides its
|
||||
//! pose, and LM alone may land it mirrored) is placed by its reference pose
|
||||
//! ([`Decomposition::floats`]). That is a guess at free motion ([`Placement::Float`]): the
|
||||
//! solver starts from it, nothing is built on it.
|
||||
//!
|
||||
//! Of two mirror-image intersections, the one keeping the orientation the points had before
|
||||
//! the solve is kept, so a dimension edit keeps each triangle's orientation, each arc on its
|
||||
//! side of its tangent lines and each line's direction instead of letting the iteration fall
|
||||
//! into a mirrored solution.
|
||||
//!
|
||||
//! The seed gives the iteration its start. Constructed values the constraints pin down one
|
||||
//! after another, and whose constraints hold there, stay constants while LM solves the rest
|
||||
//! (`solver::pinned`); every constraint is still verified, and the rank analysis of the solved
|
||||
//! Jacobian still decides the degrees of freedom. A point with only one locus on placed points keeps its own position (projecting
|
||||
//! it onto that locus would freeze a point that still has a degree of freedom). What neither
|
||||
//! step places (triconnected remainders, under-constrained freedom, constraints read by
|
||||
//! neither) is left to LM.
|
||||
//!
|
||||
//! What the constraints say about a point, and where two of them put it, is read by
|
||||
//! [`crate::loci`] (the same reading drives the decomposition): coincident points; circles
|
||||
//! (distances, line lengths, arc radii, points on circles of known radius, tangent circles);
|
||||
//! rows and columns (horizontal and vertical lines and points, and offsets from them on the
|
||||
//! side the point was on); lines through placed points along a known direction (a point on a
|
||||
//! line, a line at a known angle, parallel or perpendicular to a placed line); lines at a
|
||||
//! distance from a placed line (a point at a distance, a tangent circle's centre), on the side
|
||||
//! the point was on; the radius to a touch point, normal to the line; midpoints and mirrored
|
||||
//! points. A circle's radius set by its dimensions is seeded too.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::decompose::{Budget, Decomposition, enqueue};
|
||||
use crate::loci::{MAX_LOCI, Reading, Rel, View, place, radii, read};
|
||||
use crate::model::{Sketch, in_range};
|
||||
|
||||
/// Rounds of decomposition then propagation per seed. Each round after the first must follow a
|
||||
/// propagation that placed something, so on real sketches the loop ends after a few; this
|
||||
/// bounds a hostile chain that alternates.
|
||||
const MAX_ROUNDS: usize = 16;
|
||||
|
||||
/// Start positions for the solver: `pos[i]` is the position of point `i` when it is given or
|
||||
/// constructed, `placed_by[i]` how it was placed; `radius[k]` the radius of circle or arc `k`
|
||||
/// when its dimensions set it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Seed {
|
||||
pub pos: Vec<Option<Vec2>>,
|
||||
pub placed_by: Vec<Option<Placement>>,
|
||||
pub radius: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Placement {
|
||||
/// Fixed, fixed by a constraint, a point of a fixed curve, or held for this solve.
|
||||
Given,
|
||||
/// At the intersection of two loci anchored on the placed points `a` ≤ `b` (the same point
|
||||
/// when both loci come from it, or for a coincident point). `transversal` when the loci
|
||||
/// cross (the point is pinned down well); false when they touch.
|
||||
TwoLoci { a: usize, b: usize, transversal: bool },
|
||||
/// By cluster decomposition: rigid relative to the placed points, built from crossing
|
||||
/// intersections only.
|
||||
Cluster,
|
||||
/// A point of a rigid cluster whose pose nothing decides (free, or pinned at a single
|
||||
/// placed point), placed by the cluster's reference pose: a guess at free motion, so the
|
||||
/// solver starts from it but nothing is built on it.
|
||||
Float,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
/// The solver starts from it. A touching placement is recorded but the solver starts the
|
||||
/// point where it is.
|
||||
pub(crate) fn anchors(self) -> bool {
|
||||
!matches!(self, Placement::TwoLoci { transversal: false, .. })
|
||||
}
|
||||
|
||||
/// Later points may be built on it, and the solver may keep it (`solver::pinned`): it is
|
||||
/// pinned down well and not a guess.
|
||||
pub(crate) fn builds(self) -> bool {
|
||||
self.anchors() && self != Placement::Float
|
||||
}
|
||||
}
|
||||
|
||||
/// Place the points the constraints pin down; `given[i]` marks the points that are constants
|
||||
/// for this solve. Branches are chosen to match the orientation of `reference` (positions
|
||||
/// before an interactive move) where it covers a point, of the sketch's current positions
|
||||
/// otherwise; a reference longer than the point list is ignored. `on_curve` holds the sketch's
|
||||
/// point-on-curve pairs ([`crate::solver::points_on_curves`]); the decomposition works within
|
||||
/// what is left of `budget`, and charges it.
|
||||
pub(crate) fn seed(sk: &Sketch, given: &[bool], reference: Option<&[Vec2]>, on_curve: &[(usize, usize)], budget: &mut Budget) -> Seed {
|
||||
let n = sk.points.len();
|
||||
let reference = reference.filter(|r| r.len() <= n);
|
||||
let before = |i: usize| match reference.and_then(|r| r.get(i)) {
|
||||
Some(p) => Some(*p),
|
||||
None => sk.points.get(i).map(|p| p.pos),
|
||||
};
|
||||
let is_given = |i: usize| given.get(i).copied().unwrap_or(false);
|
||||
let pos: Vec<Option<Vec2>> = sk.points.iter().enumerate().map(|(i, p)| (is_given(i) && in_range(p.pos)).then_some(p.pos)).collect();
|
||||
let placed_by = pos.iter().map(|p| p.map(|_| Placement::Given)).collect();
|
||||
let mut out = Seed { pos, placed_by, radius: radii(sk) };
|
||||
let reading = read(sk, &before, on_curve, &out.radius);
|
||||
let mut prop = Propagation {
|
||||
reading: &reading,
|
||||
free: (0..n).map(|i| !is_given(i)).collect(),
|
||||
anchors: out.pos.clone(),
|
||||
placed_near: vec![0; n],
|
||||
queued: vec![false; n],
|
||||
queue: VecDeque::new(),
|
||||
};
|
||||
for i in 0..n {
|
||||
if prop.anchors.get(i).is_some_and(Option::is_some) {
|
||||
prop.anchored(i);
|
||||
}
|
||||
}
|
||||
// Every free point is examined once, and again when a neighbour placed later can complete
|
||||
// a pair of loci (see `Propagation::anchored`); there is nothing to examine when no free
|
||||
// point is related to a given one.
|
||||
if prop.next_to_free() {
|
||||
for i in (0..n).filter(|i| !is_given(*i)) {
|
||||
enqueue(&mut prop.queued, &mut prop.queue, i);
|
||||
}
|
||||
}
|
||||
let mut steps = n.saturating_add(reading.tie_count()).saturating_mul(2 * (MAX_LOCI + 1));
|
||||
prop.run(&mut out, &mut steps);
|
||||
// Where propagation stalls, the decomposition places what is rigid relative to the
|
||||
// anchors; propagation goes on from its placements, and the graph takes in what
|
||||
// propagation placed.
|
||||
if !switch::decompose_on() {
|
||||
return out;
|
||||
}
|
||||
// A drag step solves against its reference and moves only held points; any other solve
|
||||
// applies edits.
|
||||
let edits = reference.is_none();
|
||||
let mut clusters = Decomposition::new(&reading, &prop.anchors, edits, budget.left());
|
||||
if !clusters.built() {
|
||||
return out;
|
||||
}
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
let candidates: Vec<(usize, Vec2)> = (0..n).filter(|i| prop.is_free(*i)).filter_map(|i| Some((i, clusters.pos(i)?))).collect();
|
||||
let accepted = prop.consistent(&candidates);
|
||||
if accepted.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (i, at) in accepted {
|
||||
prop.place(&mut out, i, at, Placement::Cluster);
|
||||
}
|
||||
let anchored = prop.run(&mut out, &mut steps);
|
||||
if anchored.is_empty() || clusters.exhausted() {
|
||||
break;
|
||||
}
|
||||
clusters.know(&anchored);
|
||||
}
|
||||
// What the constraints decide is placed. A rigid cluster they leave free to move goes where
|
||||
// its reference pose puts it, if that holds every relation to the anchors and no point of
|
||||
// it is placed already: a guess, so it anchors nothing and the next is checked against the
|
||||
// anchors alone.
|
||||
for float in clusters.floats(&|i| prop.anchors.get(i).copied().flatten()) {
|
||||
let open = float.iter().all(|(i, _)| out.pos.get(*i).is_some_and(Option::is_none));
|
||||
if open && prop.consistent(&float).len() == float.len() {
|
||||
for (i, at) in float {
|
||||
prop.place(&mut out, i, at, Placement::Float);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (work, cut) = clusters.work();
|
||||
budget.charge(work, cut);
|
||||
out
|
||||
}
|
||||
|
||||
/// Point-by-point placement state, kept across the decomposition rounds.
|
||||
struct Propagation<'a> {
|
||||
reading: &'a Reading,
|
||||
/// Not given: may be placed.
|
||||
free: Vec<bool>,
|
||||
/// Points later points may be built on (see [`Placement::anchors`]).
|
||||
anchors: Vec<Option<Vec2>>,
|
||||
/// Anchoring neighbours (by a relation between two points) per point.
|
||||
placed_near: Vec<usize>,
|
||||
queued: Vec<bool>,
|
||||
queue: VecDeque<usize>,
|
||||
}
|
||||
|
||||
impl Propagation<'_> {
|
||||
fn is_free(&self, i: usize) -> bool {
|
||||
self.free.get(i).copied().unwrap_or(false) && self.anchors.get(i).is_some_and(Option::is_none)
|
||||
}
|
||||
|
||||
/// Some free point is related to an anchor.
|
||||
fn next_to_free(&self) -> bool {
|
||||
let anchored = |o: usize| self.anchors.get(o).is_some_and(Option::is_some);
|
||||
(0..self.free.len()).any(|i| self.is_free(i) && self.reading.neighbours(i).any(anchored))
|
||||
}
|
||||
|
||||
/// The candidate positions of free points, in range, that hold every relation to the
|
||||
/// anchors and to the other candidates, as a propagated point must. One that misses is not
|
||||
/// placed, nor is any candidate related to it (the decomposition may have built it on the
|
||||
/// miss).
|
||||
fn consistent(&self, candidates: &[(usize, Vec2)]) -> Vec<(usize, Vec2)> {
|
||||
let by_point: BTreeMap<usize, Vec2> = candidates.iter().copied().filter(|(i, at)| self.is_free(*i) && in_range(*at)).collect();
|
||||
let at = |i: usize| self.anchors.get(i).copied().flatten().or(by_point.get(&i).copied());
|
||||
let view = View { at: &at, oriented: true };
|
||||
let mut dropped: BTreeSet<usize> = by_point.iter().filter(|(i, x)| !self.reading.holds(**i, **x, &view)).map(|(i, _)| *i).collect();
|
||||
let mut cascade: Vec<usize> = dropped.iter().copied().collect();
|
||||
while let Some(i) = cascade.pop() {
|
||||
for o in self.reading.neighbours(i) {
|
||||
if by_point.contains_key(&o) && dropped.insert(o) {
|
||||
cascade.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
by_point.into_iter().filter(|(i, _)| !dropped.contains(i)).collect()
|
||||
}
|
||||
|
||||
/// Record `p` at `at`; an anchoring placement also anchors it and requeues the neighbours it
|
||||
/// may complete (true then). A given point is never placed.
|
||||
fn place(&mut self, out: &mut Seed, p: usize, at: Vec2, how: Placement) -> bool {
|
||||
if !self.is_free(p) {
|
||||
return false;
|
||||
}
|
||||
if let (Some(slot), Some(by)) = (out.pos.get_mut(p), out.placed_by.get_mut(p)) {
|
||||
*slot = Some(at);
|
||||
*by = Some(how);
|
||||
}
|
||||
if how.builds()
|
||||
&& let Some(slot) = self.anchors.get_mut(p)
|
||||
{
|
||||
*slot = Some(at);
|
||||
self.anchored(p);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Count the new anchor `p` at each neighbour, and requeue a free neighbour it can complete a
|
||||
/// pair of loci for (up to the MAX_LOCI-th anchor): a coincident neighbour, the second
|
||||
/// anchor, the first of a point with ties (they may give its other locus), and the points
|
||||
/// of the ties `p` may complete.
|
||||
fn anchored(&mut self, p: usize) {
|
||||
let Propagation { reading, free, anchors, placed_near, queued, queue } = self;
|
||||
let open = |o: usize| free.get(o).copied().unwrap_or(false) && anchors.get(o).is_some_and(Option::is_none);
|
||||
for e in reading.adj(p) {
|
||||
let o = e.other(p);
|
||||
let Some(count) = placed_near.get_mut(o) else { continue };
|
||||
*count += 1;
|
||||
if *count <= MAX_LOCI && (matches!(e.rel, Rel::Same) || *count >= 2 || reading.tie_count_of(o) > 0) && open(o) {
|
||||
enqueue(queued, queue, o);
|
||||
}
|
||||
}
|
||||
for o in reading.woken(p) {
|
||||
if open(o) {
|
||||
enqueue(queued, queue, o);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Place queued points until the queue or `steps` runs out; the points that became anchors.
|
||||
fn run(&mut self, out: &mut Seed, steps: &mut usize) -> Vec<(usize, Vec2)> {
|
||||
let mut anchored = Vec::new();
|
||||
while *steps > 0
|
||||
&& let Some(p) = self.queue.pop_front()
|
||||
{
|
||||
*steps -= 1;
|
||||
if let Some(q) = self.queued.get_mut(p) {
|
||||
*q = false;
|
||||
}
|
||||
if !self.is_free(p) {
|
||||
continue;
|
||||
}
|
||||
let anchors = &self.anchors;
|
||||
let at = |i: usize| anchors.get(i).copied().flatten();
|
||||
let items = self.reading.items(p, &View { at: &at, oriented: true });
|
||||
let Some(fix) = place(&items, self.reading.before(p)) else { continue };
|
||||
let how = Placement::TwoLoci { a: fix.a, b: fix.b, transversal: fix.transversal };
|
||||
if self.place(out, p, fix.at, how) {
|
||||
anchored.push((p, fix.at));
|
||||
}
|
||||
}
|
||||
anchored
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod switch {
|
||||
//! Test-only switches: the seed, or only its decomposition step, can be turned off, and
|
||||
//! every solve in this crate's tests is repeated without the seed to check that the
|
||||
//! outcome is the same.
|
||||
use std::cell::Cell;
|
||||
use std::thread::LocalKey;
|
||||
|
||||
thread_local! {
|
||||
static SEED: Cell<bool> = const { Cell::new(true) };
|
||||
static DECOMPOSE: Cell<bool> = const { Cell::new(true) };
|
||||
static DIFFERENTIAL: Cell<bool> = const { Cell::new(true) };
|
||||
}
|
||||
|
||||
pub(crate) fn seed_on() -> bool {
|
||||
SEED.with(Cell::get)
|
||||
}
|
||||
pub(crate) fn decompose_on() -> bool {
|
||||
DECOMPOSE.with(Cell::get)
|
||||
}
|
||||
pub(crate) fn differential_on() -> bool {
|
||||
DIFFERENTIAL.with(Cell::get)
|
||||
}
|
||||
|
||||
/// Restores a switch when dropped, also when the test panics.
|
||||
struct Restore(&'static LocalKey<Cell<bool>>, bool);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
self.0.with(|s| s.set(self.1));
|
||||
}
|
||||
}
|
||||
|
||||
fn off<T>(key: &'static LocalKey<Cell<bool>>, f: impl FnOnce() -> T) -> T {
|
||||
let _restore = Restore(key, key.with(|s| s.replace(false)));
|
||||
f()
|
||||
}
|
||||
/// Run `f` with the seed off.
|
||||
pub(crate) fn without_seed<T>(f: impl FnOnce() -> T) -> T {
|
||||
off(&SEED, f)
|
||||
}
|
||||
/// Run `f` with the seed's propagation only (no decomposition).
|
||||
pub(crate) fn without_decomposition<T>(f: impl FnOnce() -> T) -> T {
|
||||
off(&DECOMPOSE, f)
|
||||
}
|
||||
/// Run `f` without the seed-on/seed-off comparison (for tests that measure the difference).
|
||||
pub(crate) fn without_differential<T>(f: impl FnOnce() -> T) -> T {
|
||||
off(&DIFFERENTIAL, f)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) mod switch {
|
||||
pub(crate) const fn seed_on() -> bool {
|
||||
true
|
||||
}
|
||||
pub(crate) const fn decompose_on() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,813 @@
|
||||
//! Harness for the constructive seed on lines, arcs and tangency: a filleted rectangle, a slot,
|
||||
//! a tangent line–arc–line chain (also at axis-aligned line angles), a bolt circle, a mirrored
|
||||
//! point, collinear lines, points at a distance from a line, equal lines and points on an arc
|
||||
//! of unknown radius, under scaled dimension edits. Every arc must keep its side of its lines
|
||||
//! and its sweep, every line its direction, every point its side of its lines.
|
||||
//! Deterministic (fixed LCG seeds), no timing.
|
||||
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::construct::{Placement, seed};
|
||||
use crate::decompose::Budget;
|
||||
use crate::solver::points_on_curves;
|
||||
use crate::tests::{Lcg, given, seed_of, seeded, v};
|
||||
use crate::*;
|
||||
|
||||
/// What an edit must keep.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Check {
|
||||
/// `p` stays on its side of the line through `a` and `b`.
|
||||
Side { p: usize, a: usize, b: usize },
|
||||
/// The line from `a` to `b` points within a quarter turn of `d` (where the edit turns it).
|
||||
Toward { a: usize, b: usize, d: Vec2 },
|
||||
/// `p` stays ahead of or behind `a` along the line from `a` to `b`.
|
||||
Along { p: usize, a: usize, b: usize },
|
||||
/// The counter-clockwise sweep of the arc `curve` stays in `lo..hi` (radians).
|
||||
Sweep { curve: usize, lo: f64, hi: f64 },
|
||||
/// `p` ends at `at`.
|
||||
At { p: usize, at: Vec2 },
|
||||
}
|
||||
|
||||
/// A family member: the solved sketch, the dimension edits (constraint index, new value) and
|
||||
/// what they must keep.
|
||||
struct Case {
|
||||
sk: Sketch,
|
||||
edits: Vec<(usize, f64)>,
|
||||
checks: Vec<Check>,
|
||||
}
|
||||
|
||||
fn sweep(sk: &Sketch, curve: usize) -> f64 {
|
||||
let CurveKind::Arc { c, a, b } = sk.curves[curve].kind else { return f64::NAN };
|
||||
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)
|
||||
}
|
||||
|
||||
/// Does `sk` keep every check (sides as in `before`)?
|
||||
fn holds(sk: &Sketch, checks: &[Check], before: &Sketch) -> bool {
|
||||
let p = |i: usize| sk.points[i].pos;
|
||||
checks.iter().all(|c| match *c {
|
||||
Check::Side { p: x, a, b } => {
|
||||
let side = |s: &Sketch| (s.points[b].pos - s.points[a].pos).cross(s.points[x].pos - s.points[a].pos).signum();
|
||||
side(sk) == side(before)
|
||||
}
|
||||
Check::Toward { a, b, d } => (p(b) - p(a)).dot(d) > 0.0,
|
||||
Check::Along { p: x, a, b } => {
|
||||
let along = |s: &Sketch| (s.points[b].pos - s.points[a].pos).dot(s.points[x].pos - s.points[a].pos).signum();
|
||||
along(sk) == along(before)
|
||||
}
|
||||
Check::Sweep { curve, lo, hi } => (lo..hi).contains(&sweep(sk, curve)),
|
||||
Check::At { p: x, at } => p(x).dist(at) < 1e-6,
|
||||
})
|
||||
}
|
||||
|
||||
/// The member's sketch with its edits applied.
|
||||
fn edited(case: &Case) -> Sketch {
|
||||
let mut sk = case.sk.clone();
|
||||
for (i, value) in &case.edits {
|
||||
sk.constraints[*i].kind.set_value(*value);
|
||||
}
|
||||
sk
|
||||
}
|
||||
|
||||
/// Apply the edits and solve; (failed, a check broke).
|
||||
fn run(case: &Case, on: bool) -> (bool, bool) {
|
||||
let mut sk = edited(case);
|
||||
let rep = seeded(on, || solve(&mut sk));
|
||||
if !rep.ok() {
|
||||
return (true, false);
|
||||
}
|
||||
(false, !holds(&sk, &case.checks, &case.sk))
|
||||
}
|
||||
|
||||
fn push(sk: &mut Sketch, kind: ConstraintKind) -> usize {
|
||||
sk.add_constraint(kind, None).unwrap();
|
||||
sk.constraints.len() - 1
|
||||
}
|
||||
|
||||
/// The four arcs' ends and centres of a rounded rectangle, CCW: bottom, right, top and left
|
||||
/// lines between quarter arcs at the corners, every line tangent to both its arcs. The bottom
|
||||
/// line's start is fixed; lengths of the bottom and left lines and the four radii are
|
||||
/// dimensions, scaled by `lo..hi`. Only edits where the top and right lines keep a length are
|
||||
/// kept.
|
||||
fn filleted_rectangle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let o = v(rnd.range(-50.0, 50.0), rnd.range(-50.0, 50.0));
|
||||
let (w, h) = (rnd.range(60.0, 100.0), rnd.range(40.0, 70.0));
|
||||
let r: [f64; 4] = std::array::from_fn(|_| rnd.range(4.0, 12.0)); // BR, TR, TL, BL
|
||||
let mut sk = Sketch::new();
|
||||
let at = |x: f64, y: f64| o + v(x, y);
|
||||
let bottom = sk.add_line(at(r[3], 0.0), at(w - r[0], 0.0), None, None, None).unwrap();
|
||||
let right = sk.add_line(at(w, r[0]), at(w, h - r[1]), None, None, None).unwrap();
|
||||
let top = sk.add_line(at(w - r[1], h), at(r[2], h), None, None, None).unwrap();
|
||||
let left = sk.add_line(at(0.0, h - r[2]), at(0.0, r[3]), None, None, None).unwrap();
|
||||
let ends = |sk: &Sketch, l: usize| sk.curves[l].kind.ends().unwrap();
|
||||
let ((b0, b1), (r0, r1), (t0, t1), (l0, l1)) = (ends(&sk, bottom), ends(&sk, right), ends(&sk, top), ends(&sk, left));
|
||||
let corners = [
|
||||
(at(w - r[0], r[0]), b1, r0, bottom, right),
|
||||
(at(w - r[1], h - r[1]), r1, t0, right, top),
|
||||
(at(r[2], h - r[2]), t1, l0, top, left),
|
||||
(at(r[3], r[3]), l1, b0, left, bottom),
|
||||
];
|
||||
let mut checks = Vec::new();
|
||||
let mut radius = Vec::new();
|
||||
for (k, (c, a, b, la, lb)) in corners.into_iter().enumerate() {
|
||||
let (pa, pb) = (sk.points[a].pos, sk.points[b].pos);
|
||||
let arc = sk.add_arc(c, pa, pb, [None, Some(a), Some(b)], None).unwrap();
|
||||
let CurveKind::Arc { c: centre, .. } = sk.curves[arc].kind else { panic!() };
|
||||
push(&mut sk, ConstraintKind::Tangent { a: la, b: arc });
|
||||
push(&mut sk, ConstraintKind::Tangent { a: lb, b: arc });
|
||||
radius.push((push(&mut sk, ConstraintKind::Radius { c: arc, value: r[k] }), r[k]));
|
||||
for l in [la, lb] {
|
||||
let (a, b) = ends(&sk, l);
|
||||
checks.push(Check::Side { p: centre, a, b });
|
||||
}
|
||||
checks.push(Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI });
|
||||
}
|
||||
for l in [bottom, right, top, left] {
|
||||
let (a, b) = ends(&sk, l);
|
||||
checks.push(Check::Toward { a, b, d: sk.points[b].pos - sk.points[a].pos });
|
||||
}
|
||||
push(&mut sk, ConstraintKind::Fix { p: b0 });
|
||||
push(&mut sk, ConstraintKind::Horizontal { l: bottom });
|
||||
push(&mut sk, ConstraintKind::Horizontal { l: top });
|
||||
push(&mut sk, ConstraintKind::Vertical { l: left });
|
||||
push(&mut sk, ConstraintKind::Vertical { l: right });
|
||||
let (lb, ll) = (w - r[3] - r[0], h - r[2] - r[3]);
|
||||
let len_b = push(&mut sk, ConstraintKind::Length { l: bottom, value: lb });
|
||||
let len_l = push(&mut sk, ConstraintKind::Length { l: left, value: ll });
|
||||
let mut s = || rnd.range(lo, hi);
|
||||
let r2: Vec<f64> = radius.iter().map(|(_, r)| r * s()).collect();
|
||||
let (lb2, ll2) = (lb * s(), ll * s());
|
||||
// Width and height after the edit; the top and right lines must keep a length.
|
||||
let (w2, h2) = (lb2 + r2[3] + r2[0], ll2 + r2[2] + r2[3]);
|
||||
if w2 - r2[1] - r2[2] < 2.0 || h2 - r2[0] - r2[1] < 2.0 {
|
||||
return None;
|
||||
}
|
||||
let mut edits: Vec<(usize, f64)> = radius.iter().zip(&r2).map(|((i, _), r)| (*i, *r)).collect();
|
||||
edits.extend([(len_b, lb2), (len_l, ll2)]);
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
/// A slot at an angle: two semicircular caps of equal radius joined by two lines tangent to
|
||||
/// both, the bottom line at a line angle and the top one parallel to it, cap A's centre fixed.
|
||||
/// The angle turns by up to ±145°, the radius and the cap distance (centre distance, or the
|
||||
/// overall length) scale by `lo..hi`.
|
||||
fn slot(rnd: &mut Lcg, lo: f64, hi: f64, overall: bool) -> Option<Case> {
|
||||
let th = rnd.range(-3.1, 3.1);
|
||||
let (len, r) = (rnd.range(30.0, 60.0), rnd.range(5.0, 13.0));
|
||||
let (u, n) = (Vec2::from_angle(th), Vec2::from_angle(th).perp());
|
||||
let mut sk = Sketch::new();
|
||||
let ca = v(rnd.range(-40.0, 40.0), rnd.range(-40.0, 40.0));
|
||||
let cb = ca + u * len;
|
||||
let bottom = sk.add_line(ca - n * r, cb - n * r, None, None, None).unwrap();
|
||||
let top = sk.add_line(cb + n * r, ca + n * r, None, None, None).unwrap();
|
||||
let (ba, bb) = sk.curves[bottom].kind.ends().unwrap();
|
||||
let (tb, ta) = sk.curves[top].kind.ends().unwrap();
|
||||
let arc_b = sk.add_arc(cb, cb - n * r, cb + n * r, [None, Some(bb), Some(tb)], None).unwrap();
|
||||
let arc_a = sk.add_arc(ca, ca + n * r, ca - n * r, [None, Some(ta), Some(ba)], None).unwrap();
|
||||
let centre = |sk: &Sketch, a: usize| match sk.curves[a].kind {
|
||||
CurveKind::Arc { c, .. } => c,
|
||||
_ => panic!(),
|
||||
};
|
||||
let (pa, pb) = (centre(&sk, arc_a), centre(&sk, arc_b));
|
||||
push(&mut sk, ConstraintKind::Fix { p: pa });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: bottom, value: th });
|
||||
push(&mut sk, ConstraintKind::Parallel { a: top, b: bottom });
|
||||
for (l, a) in [(bottom, arc_a), (bottom, arc_b), (top, arc_a), (top, arc_b)] {
|
||||
push(&mut sk, ConstraintKind::Tangent { a: l, b: a });
|
||||
}
|
||||
let radius = push(&mut sk, ConstraintKind::Radius { c: arc_a, value: r });
|
||||
push(&mut sk, ConstraintKind::Equal { a: arc_a, b: arc_b });
|
||||
let length = if overall {
|
||||
push(&mut sk, ConstraintKind::SlotOverallLength { a: arc_a, b: arc_b, value: len + 2.0 * r })
|
||||
} else {
|
||||
push(&mut sk, ConstraintKind::Distance { p: pa, q: pb, value: len })
|
||||
};
|
||||
let (r2, len2) = (r * rnd.range(lo, hi), len * rnd.range(lo, hi));
|
||||
let th2 = th + rnd.range(-2.5, 2.5);
|
||||
let half = std::f64::consts::FRAC_PI_2;
|
||||
let checks = vec![
|
||||
Check::Side { p: pa, a: ba, b: bb },
|
||||
Check::Side { p: pb, a: ba, b: bb },
|
||||
Check::Side { p: pa, a: tb, b: ta },
|
||||
Check::Side { p: pb, a: tb, b: ta },
|
||||
Check::Sweep { curve: arc_a, lo: half, hi: 3.0 * half },
|
||||
Check::Sweep { curve: arc_b, lo: half, hi: 3.0 * half },
|
||||
Check::Toward { a: pa, b: pb, d: Vec2::from_angle(th2) },
|
||||
];
|
||||
let edits = vec![(angle, th2), (radius, r2), (length, if overall { len2 + 2.0 * r2 } else { len2 })];
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
/// Line – arc – line, tangent at shared ends: L1 from a fixed point at a line angle with a
|
||||
/// length, the arc turning left or right by β with a radius, L2 at β from L1 with a length.
|
||||
/// Lengths and radius scale by `lo..hi`, the line angle turns by up to ±115°, β scales within
|
||||
/// 15°–165°.
|
||||
fn tangent_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let alpha = rnd.range(-3.1, 3.1);
|
||||
let alpha2 = alpha + rnd.range(-2.0, 2.0);
|
||||
chain(rnd, lo, hi, alpha, alpha2)
|
||||
}
|
||||
|
||||
/// [`tangent_chain`] with L1 along an axis, turned to another axis (a quarter or half turn
|
||||
/// either way).
|
||||
fn axis_chain(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let quarter = std::f64::consts::FRAC_PI_2;
|
||||
let alpha = quarter * (rnd.next() * 4.0).floor() - std::f64::consts::PI;
|
||||
let turn = [-2.0, -1.0, 1.0, 2.0][(rnd.next() * 4.0) as usize % 4];
|
||||
chain(rnd, lo, hi, alpha, alpha + quarter * turn)
|
||||
}
|
||||
|
||||
/// The chain of [`tangent_chain`] with L1 at `alpha`, edited to `alpha2`.
|
||||
fn chain(rnd: &mut Lcg, lo: f64, hi: f64, alpha: f64, alpha2: f64) -> Option<Case> {
|
||||
let (a, b, r) = (rnd.range(20.0, 40.0), rnd.range(20.0, 40.0), rnd.range(5.0, 15.0));
|
||||
let beta = rnd.range(30f64.to_radians(), 150f64.to_radians());
|
||||
let turn = rnd.sign();
|
||||
let p0 = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
|
||||
let u1 = Vec2::from_angle(alpha);
|
||||
let u2 = Vec2::from_angle(alpha + turn * beta);
|
||||
let e1 = p0 + u1 * a;
|
||||
let c = e1 + u1.perp() * (turn * r);
|
||||
let e2 = c - u2.perp() * (turn * r);
|
||||
let p3 = e2 + u2 * b;
|
||||
let mut sk = Sketch::new();
|
||||
let l1 = sk.add_line(p0, e1, None, None, None).unwrap();
|
||||
let (i0, i1) = sk.curves[l1].kind.ends().unwrap();
|
||||
let l2 = sk.add_line(e2, p3, None, None, None).unwrap();
|
||||
let (i2, i3) = sk.curves[l2].kind.ends().unwrap();
|
||||
let arc = if turn > 0.0 {
|
||||
sk.add_arc(c, e1, e2, [None, Some(i1), Some(i2)], None).unwrap()
|
||||
} else {
|
||||
sk.add_arc(c, e2, e1, [None, Some(i2), Some(i1)], None).unwrap()
|
||||
};
|
||||
let CurveKind::Arc { c: ic, .. } = sk.curves[arc].kind else { panic!() };
|
||||
push(&mut sk, ConstraintKind::Fix { p: i0 });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: alpha });
|
||||
let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a });
|
||||
let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: turn * beta, flip: false });
|
||||
let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b });
|
||||
let radius = push(&mut sk, ConstraintKind::Radius { c: arc, value: r });
|
||||
push(&mut sk, ConstraintKind::Tangent { a: l1, b: arc });
|
||||
push(&mut sk, ConstraintKind::Tangent { a: l2, b: arc });
|
||||
let mut s = || rnd.range(lo, hi);
|
||||
let beta2 = (beta * s()).clamp(15f64.to_radians(), 165f64.to_radians());
|
||||
let edits = vec![(len1, a * s()), (len2, b * s()), (radius, r * s()), (bend, turn * beta2), (angle, alpha2)];
|
||||
let checks = vec![
|
||||
Check::Side { p: ic, a: i0, b: i1 },
|
||||
Check::Side { p: ic, a: i2, b: i3 },
|
||||
Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI },
|
||||
Check::Toward { a: i0, b: i1, d: Vec2::from_angle(alpha2) },
|
||||
Check::Toward { a: i2, b: i3, d: Vec2::from_angle(alpha2 + turn * beta2) },
|
||||
];
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
/// Bolt circle: `k` points on a circle (diameter dimension, centre fixed), each on a line from
|
||||
/// the centre; the first line at a line angle, each next at 360°/k from the one before. The
|
||||
/// diameter scales by `lo..hi`, the first angle turns by up to ±170°; every point must end where
|
||||
/// the dimensions put it.
|
||||
fn bolt_circle(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let k = 3 + (rnd.next() * 6.0) as usize;
|
||||
let r = rnd.range(15.0, 40.0);
|
||||
let phi = rnd.range(-3.1, 3.1);
|
||||
let step = std::f64::consts::TAU / k as f64;
|
||||
let mut sk = Sketch::new();
|
||||
let centre = sk.add_point(v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)), None).unwrap();
|
||||
let c = sk.points[centre].pos;
|
||||
let circle = sk.add_circle(c, r, Some(centre), None).unwrap();
|
||||
push(&mut sk, ConstraintKind::Fix { p: centre });
|
||||
let diameter = push(&mut sk, ConstraintKind::Diameter { c: circle, value: 2.0 * r });
|
||||
let mut lines = Vec::new();
|
||||
let mut holes = Vec::new();
|
||||
for i in 0..k {
|
||||
let h = sk.add_point(c + Vec2::from_angle(phi + i as f64 * step) * r, None).unwrap();
|
||||
lines.push(sk.add_line_pts(centre, h, None).unwrap());
|
||||
holes.push(h);
|
||||
push(&mut sk, ConstraintKind::PointOnCurve { p: h, c: circle });
|
||||
}
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: lines[0], value: phi });
|
||||
for w in lines.windows(2) {
|
||||
push(&mut sk, ConstraintKind::Angle { a: w[0], b: w[1], value: step, flip: false });
|
||||
}
|
||||
let (r2, phi2) = (r * rnd.range(lo, hi), phi + rnd.range(-3.0, 3.0));
|
||||
let checks = holes.iter().enumerate().map(|(i, h)| Check::At { p: *h, at: c + Vec2::from_angle(phi2 + i as f64 * step) * r2 }).collect();
|
||||
Some(Case { sk, edits: vec![(diameter, 2.0 * r2), (angle, phi2)], checks })
|
||||
}
|
||||
|
||||
/// A point mirrored across a line: the line from a fixed point at a line angle with a length,
|
||||
/// `p` at two distances from its ends (one side), `q` its mirror image. The angle turns by up
|
||||
/// to ±115°, the dimensions scale by `lo..hi` (only edits where the triangle still exists).
|
||||
fn mirrored(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let theta = rnd.range(-3.1, 3.1);
|
||||
let len = rnd.range(30.0, 60.0);
|
||||
let (u, a) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
|
||||
let (t, h) = (rnd.range(0.2, 0.8) * len, rnd.range(5.0, 20.0) * rnd.sign());
|
||||
let (p, q) = (a + u * t + u.perp() * h, a + u * t - u.perp() * h);
|
||||
let mut sk = Sketch::new();
|
||||
let axis = sk.add_line(a, a + u * len, None, None, None).unwrap();
|
||||
let (ia, ib) = sk.curves[axis].kind.ends().unwrap();
|
||||
let (ip, iq) = (sk.add_point(p, None).unwrap(), sk.add_point(q, None).unwrap());
|
||||
push(&mut sk, ConstraintKind::Fix { p: ia });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: axis, value: theta });
|
||||
let length = push(&mut sk, ConstraintKind::Length { l: axis, value: len });
|
||||
let (d1, d2) = (p.dist(a), p.dist(a + u * len));
|
||||
let k1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 });
|
||||
let k2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ip, value: d2 });
|
||||
push(&mut sk, ConstraintKind::Symmetric { p: ip, q: iq, l: axis });
|
||||
let theta2 = theta + rnd.range(-2.0, 2.0);
|
||||
let mut s = || rnd.range(lo, hi);
|
||||
let (len2, e1, e2) = (len * s(), d1 * s(), d2 * s());
|
||||
if e1 + e2 < len2 * 1.02 || (e1 - e2).abs() > len2 * 0.98 {
|
||||
return None;
|
||||
}
|
||||
let checks =
|
||||
vec![Check::Side { p: ip, a: ia, b: ib }, Check::Side { p: iq, a: ia, b: ib }, Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) }];
|
||||
Some(Case { sk, edits: vec![(angle, theta2), (length, len2), (k1, e1), (k2, e2)], checks })
|
||||
}
|
||||
|
||||
/// Two collinear lines: L1 from a fixed point at a line angle with a length, L2 collinear with
|
||||
/// it, its start a distance past L1's end, with a length. The angle turns by up to ±115°, the
|
||||
/// dimensions scale by `lo..hi`; both lines and the gap keep their direction.
|
||||
fn collinear(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let theta = rnd.range(-3.1, 3.1);
|
||||
let (a, g, b) = (rnd.range(20.0, 40.0), rnd.range(5.0, 30.0), rnd.range(20.0, 40.0));
|
||||
let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
|
||||
let mut sk = Sketch::new();
|
||||
let l1 = sk.add_line(o, o + u * a, None, None, None).unwrap();
|
||||
let l2 = sk.add_line(o + u * (a + g), o + u * (a + g + b), None, None, None).unwrap();
|
||||
let ((i0, i1), (i2, i3)) = (sk.curves[l1].kind.ends().unwrap(), sk.curves[l2].kind.ends().unwrap());
|
||||
push(&mut sk, ConstraintKind::Fix { p: i0 });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta });
|
||||
let len1 = push(&mut sk, ConstraintKind::Length { l: l1, value: a });
|
||||
push(&mut sk, ConstraintKind::Collinear { a: l1, b: l2 });
|
||||
let gap = push(&mut sk, ConstraintKind::Distance { p: i1, q: i2, value: g });
|
||||
let len2 = push(&mut sk, ConstraintKind::Length { l: l2, value: b });
|
||||
let theta2 = theta + rnd.range(-2.0, 2.0);
|
||||
let u2 = Vec2::from_angle(theta2);
|
||||
let mut s = || rnd.range(lo, hi);
|
||||
let edits = vec![(angle, theta2), (len1, a * s()), (gap, g * s()), (len2, b * s())];
|
||||
let checks = vec![Check::Toward { a: i0, b: i1, d: u2 }, Check::Toward { a: i1, b: i2, d: u2 }, Check::Toward { a: i2, b: i3, d: u2 }];
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
/// Points at a distance from a line: the line from a fixed point at a line angle with a
|
||||
/// length; `p` at a distance from it (left) and from its start, `r` at half a linear diameter
|
||||
/// from it (right) and at a distance from its end. The angle turns by up to ±115°, the
|
||||
/// dimensions scale by `lo..hi` (only edits where both points still exist).
|
||||
fn line_distance(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let theta = rnd.range(-3.1, 3.1);
|
||||
let len = rnd.range(30.0, 60.0);
|
||||
let (u, o) = (Vec2::from_angle(theta), v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0)));
|
||||
let n = u.perp();
|
||||
let (h1, h2) = (rnd.range(5.0, 20.0), rnd.range(5.0, 20.0));
|
||||
let p = o + u * rnd.range(5.0, 25.0) + n * h1;
|
||||
let r = o + u * (len - rnd.range(5.0, 25.0)) - n * h2;
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(o, o + u * len, None, None, None).unwrap();
|
||||
let (ia, ib) = sk.curves[l].kind.ends().unwrap();
|
||||
let (ip, ir) = (sk.add_point(p, None).unwrap(), sk.add_point(r, None).unwrap());
|
||||
push(&mut sk, ConstraintKind::Fix { p: ia });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l, value: theta });
|
||||
let length = push(&mut sk, ConstraintKind::Length { l, value: len });
|
||||
let kh1 = push(&mut sk, ConstraintKind::PointLineDistance { p: ip, l, value: h1 });
|
||||
let (d1, d2) = (p.dist(o), r.dist(o + u * len));
|
||||
let kd1 = push(&mut sk, ConstraintKind::Distance { p: ia, q: ip, value: d1 });
|
||||
let kh2 = push(&mut sk, ConstraintKind::LinearDiameter { p: ir, l, value: 2.0 * h2 });
|
||||
let kd2 = push(&mut sk, ConstraintKind::Distance { p: ib, q: ir, value: d2 });
|
||||
let theta2 = theta + rnd.range(-2.0, 2.0);
|
||||
let mut s = || rnd.range(lo, hi);
|
||||
let (len2, g1, e1, g2, e2) = (len * s(), h1 * s(), d1 * s(), h2 * s(), d2 * s());
|
||||
if e1 < g1 * 1.1 || e2 < g2 * 1.1 {
|
||||
return None;
|
||||
}
|
||||
let edits = vec![(angle, theta2), (length, len2), (kh1, g1), (kd1, e1), (kh2, 2.0 * g2), (kd2, e2)];
|
||||
let checks = vec![
|
||||
Check::Side { p: ip, a: ia, b: ib },
|
||||
Check::Side { p: ir, a: ia, b: ib },
|
||||
Check::Along { p: ip, a: ia, b: ib },
|
||||
Check::Along { p: ir, a: ib, b: ia },
|
||||
Check::Toward { a: ia, b: ib, d: Vec2::from_angle(theta2) },
|
||||
];
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
/// Equal lines: L1 from a fixed point at a line angle, its end at a distance (no length
|
||||
/// dimension), L2 from L1's end at an angle β from it and equal to it. The angle turns by up
|
||||
/// to ±115°, the distance scales by `lo..hi`, β within 15°–165° either way.
|
||||
fn equal_lines(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let theta = rnd.range(-3.1, 3.1);
|
||||
let a = rnd.range(20.0, 40.0);
|
||||
let beta = rnd.range(30f64.to_radians(), 150f64.to_radians()) * rnd.sign();
|
||||
let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
|
||||
let b = o + Vec2::from_angle(theta) * a;
|
||||
let mut sk = Sketch::new();
|
||||
let l1 = sk.add_line(o, b, None, None, None).unwrap();
|
||||
let (i0, i1) = sk.curves[l1].kind.ends().unwrap();
|
||||
let l2 = sk.add_line(b, b + Vec2::from_angle(theta + beta) * a, Some(i1), None, None).unwrap();
|
||||
let (_, i2) = sk.curves[l2].kind.ends().unwrap();
|
||||
push(&mut sk, ConstraintKind::Fix { p: i0 });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: l1, value: theta });
|
||||
let dist = push(&mut sk, ConstraintKind::Distance { p: i0, q: i1, value: a });
|
||||
let bend = push(&mut sk, ConstraintKind::Angle { a: l1, b: l2, value: beta, flip: false });
|
||||
push(&mut sk, ConstraintKind::Equal { a: l1, b: l2 });
|
||||
let theta2 = theta + rnd.range(-2.0, 2.0);
|
||||
let beta2 = beta.signum() * (beta.abs() * rnd.range(lo, hi)).clamp(15f64.to_radians(), 165f64.to_radians());
|
||||
let edits = vec![(angle, theta2), (dist, a * rnd.range(lo, hi)), (bend, beta2)];
|
||||
let checks = vec![
|
||||
Check::Toward { a: i0, b: i1, d: Vec2::from_angle(theta2) },
|
||||
Check::Toward { a: i1, b: i2, d: Vec2::from_angle(theta2 + beta2) },
|
||||
Check::Side { p: i2, a: i0, b: i1 },
|
||||
];
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
/// Points on an arc of unknown radius: the arc's centre fixed, its start on a line from the
|
||||
/// centre at a line angle and a distance (no radius dimension), its end and a point on it on
|
||||
/// lines from the centre at angles σ and τ from the first. The angle turns by up to ±115°, the
|
||||
/// distance scales by `lo..hi`, σ within 20°–170° (τ keeps its share of σ).
|
||||
fn on_arc(rnd: &mut Lcg, lo: f64, hi: f64) -> Option<Case> {
|
||||
let phi = rnd.range(-3.1, 3.1);
|
||||
let r = rnd.range(15.0, 40.0);
|
||||
let sigma = rnd.range(40f64.to_radians(), 150f64.to_radians());
|
||||
let share = rnd.range(0.2, 0.8);
|
||||
let o = v(rnd.range(-30.0, 30.0), rnd.range(-30.0, 30.0));
|
||||
let at = |a: f64| o + Vec2::from_angle(a) * r;
|
||||
let mut sk = Sketch::new();
|
||||
let arc = sk.add_arc(o, at(phi), at(phi + sigma), [None, None, None], None).unwrap();
|
||||
let CurveKind::Arc { c, a: s0, b: e0 } = sk.curves[arc].kind else { panic!() };
|
||||
let p = sk.add_point(at(phi + share * sigma), None).unwrap();
|
||||
let (ls, le, lp) = (sk.add_line_pts(c, s0, None).unwrap(), sk.add_line_pts(c, e0, None).unwrap(), sk.add_line_pts(c, p, None).unwrap());
|
||||
push(&mut sk, ConstraintKind::Fix { p: c });
|
||||
let angle = push(&mut sk, ConstraintKind::LineAngle { l: ls, value: phi });
|
||||
let dist = push(&mut sk, ConstraintKind::Distance { p: c, q: s0, value: r });
|
||||
let ke = push(&mut sk, ConstraintKind::Angle { a: ls, b: le, value: sigma, flip: false });
|
||||
let kp = push(&mut sk, ConstraintKind::Angle { a: ls, b: lp, value: share * sigma, flip: false });
|
||||
push(&mut sk, ConstraintKind::PointOnCurve { p, c: arc });
|
||||
let phi2 = phi + rnd.range(-2.0, 2.0);
|
||||
let sigma2 = (sigma * rnd.range(lo, hi)).clamp(20f64.to_radians(), 170f64.to_radians());
|
||||
let edits = vec![(angle, phi2), (dist, r * rnd.range(lo, hi)), (ke, sigma2), (kp, share * sigma2)];
|
||||
let checks = vec![
|
||||
Check::Sweep { curve: arc, lo: 0.0, hi: std::f64::consts::PI },
|
||||
Check::Toward { a: c, b: e0, d: Vec2::from_angle(phi2 + sigma2) },
|
||||
Check::Toward { a: c, b: p, d: Vec2::from_angle(phi2 + share * sigma2) },
|
||||
];
|
||||
Some(Case { sk, edits, checks })
|
||||
}
|
||||
|
||||
type Family = fn(&mut Lcg, f64, f64) -> Option<Case>;
|
||||
|
||||
fn families() -> Vec<(&'static str, Family)> {
|
||||
vec![
|
||||
("filleted rectangle", filleted_rectangle),
|
||||
("slot (centre distance)", |r, lo, hi| slot(r, lo, hi, false)),
|
||||
("slot (overall length)", |r, lo, hi| slot(r, lo, hi, true)),
|
||||
("tangent chain", tangent_chain),
|
||||
("axis-aligned chain", axis_chain),
|
||||
("bolt circle", bolt_circle),
|
||||
("mirrored point", mirrored),
|
||||
("collinear lines", collinear),
|
||||
("distance from a line", line_distance),
|
||||
("equal lines", equal_lines),
|
||||
("points on an arc", on_arc),
|
||||
]
|
||||
}
|
||||
|
||||
const EDITS: usize = 200;
|
||||
|
||||
/// `n` feasible members of `family` at scales `lo..hi`; fails the test when the family yields
|
||||
/// fewer than one in fifty.
|
||||
fn sample(rnd: &mut Lcg, family: Family, lo: f64, hi: f64, n: usize) -> Vec<Case> {
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..n.saturating_mul(50) {
|
||||
if out.len() == n {
|
||||
break;
|
||||
}
|
||||
out.extend(family(rnd, lo, hi));
|
||||
}
|
||||
assert_eq!(out.len(), n, "the family yields too few feasible members at {lo}–{hi}×");
|
||||
out
|
||||
}
|
||||
|
||||
/// (failed, broke a check) over `EDITS` kept edits of `family` at scales `lo..hi`.
|
||||
fn tally(family: Family, lo: f64, hi: f64, on: bool) -> (usize, usize) {
|
||||
let mut rnd = Lcg(7);
|
||||
let (mut failed, mut broke) = (0, 0);
|
||||
for case in sample(&mut rnd, family, lo, hi, EDITS) {
|
||||
let (f, b) = run(&case, on);
|
||||
failed += f as usize;
|
||||
broke += b as usize;
|
||||
}
|
||||
(failed, broke)
|
||||
}
|
||||
|
||||
fn table(on: bool) -> Vec<(&'static str, f64, f64, usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for (name, family) in families() {
|
||||
for (lo, hi) in [(0.8, 1.25), (0.5, 2.0), (0.3, 3.0)] {
|
||||
let (failed, broke) = tally(family, lo, hi, on);
|
||||
eprintln!("seed {} {name:<24} {lo}–{hi}×: {failed} failed, {broke}/{EDITS} lost a side", if on { "on " } else { "off" });
|
||||
out.push((name, lo, hi, failed, broke));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounds_and_tangents_keep_their_sides_on_dimension_edits() {
|
||||
let rows = table(true);
|
||||
assert!(rows.iter().all(|r| r.3 == 0 && r.4 == 0), "{rows:?}");
|
||||
}
|
||||
|
||||
/// The measurement the seed is judged against: without it, LM folds arcs and lines over.
|
||||
#[test]
|
||||
#[ignore = "measurement: run with --ignored --nocapture"]
|
||||
fn rounds_and_tangents_without_the_seed() {
|
||||
table(false);
|
||||
}
|
||||
|
||||
/// The solved members of every family are left exactly where they are.
|
||||
#[test]
|
||||
fn solved_members_stay_put() {
|
||||
let mut rnd = Lcg(3);
|
||||
for (name, family) in families() {
|
||||
for case in sample(&mut rnd, family, 1.0, 1.0, 10) {
|
||||
let mut sk = case.sk.clone();
|
||||
let rep = solve(&mut sk);
|
||||
assert!(rep.ok() && rep.dof == 0, "{name}: {rep:?}");
|
||||
for (p, q) in sk.points.iter().zip(&case.sk.points) {
|
||||
assert!(p.pos.dist(q.pos) < 1e-7, "{name}: {} moved {:?} → {:?}", p.id, q.pos, p.pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The seed only moves the start: through the crate-wide seed-on/seed-off comparison (`solve`
|
||||
/// in tests), the edits give the same status, degrees of freedom and determined flags.
|
||||
#[test]
|
||||
fn seed_differential_on_every_family() {
|
||||
let mut rnd = Lcg(5);
|
||||
for (_, family) in families() {
|
||||
for case in sample(&mut rnd, family, 0.5, 2.0, 15) {
|
||||
let mut sk = edited(&case);
|
||||
solve(&mut sk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every point of every family is placed by the seed (not left to LM), so edits solve in
|
||||
/// few iterations.
|
||||
#[test]
|
||||
fn every_point_of_every_family_is_constructed() {
|
||||
let mut rnd = Lcg(9);
|
||||
for (name, family) in families() {
|
||||
for case in sample(&mut rnd, family, 0.5, 2.0, 10) {
|
||||
let sk = edited(&case);
|
||||
let s = seed_of(&sk);
|
||||
let used: Vec<usize> = sk.curves.iter().flat_map(|c| c.kind.point_ids()).collect();
|
||||
for p in used {
|
||||
assert!(s.placed_by[p].is_some_and(Placement::anchors), "{name}: point {p} ({}) not placed: {s:?}", sk.points[p].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The seed does not depend on the order of the constraints.
|
||||
#[test]
|
||||
fn seed_on_lines_and_rounds_is_independent_of_constraint_order() {
|
||||
let mut rnd = Lcg(13);
|
||||
for (name, family) in families() {
|
||||
let case = sample(&mut rnd, family, 0.5, 2.0, 1).remove(0);
|
||||
let mut sk = edited(&case);
|
||||
let forward = seed_of(&sk);
|
||||
sk.constraints.reverse();
|
||||
assert_eq!(seed_of(&sk), forward, "{name}");
|
||||
let n = sk.constraints.len();
|
||||
sk.constraints.rotate_left(n / 3);
|
||||
assert_eq!(seed_of(&sk), forward, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical bracket with the third dimension on a point the rigid triangle carries
|
||||
/// through a constraint the decomposition reads from the shared loci: the midpoint of P R, or
|
||||
/// the centre of a circle tangent to P R at a known distance from Q. No propagation order
|
||||
/// reaches P, Q, R; the decomposition grows the triangle by that point and places all four.
|
||||
#[test]
|
||||
fn decomposition_grows_clusters_through_lines_and_tangency() {
|
||||
for tangent in [false, true] {
|
||||
let truth = [v(0.0, 0.0), v(60.0, 0.0), v(10.0, 30.0), v(45.0, 35.0), v(25.0, 55.0)];
|
||||
let mut sk = Sketch::new();
|
||||
let ids: Vec<usize> = truth
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| sk.add_point(*t + if i < 2 { v(0.0, 0.0) } else { v(0.7 * i as f64, -0.4 * i as f64) }, None).unwrap())
|
||||
.collect();
|
||||
let [o1, o2, p, q, r] = [ids[0], ids[1], ids[2], ids[3], ids[4]];
|
||||
for x in [o1, o2] {
|
||||
push(&mut sk, ConstraintKind::Fix { p: x });
|
||||
}
|
||||
let pr = sk.add_line_pts(p, r, None).unwrap();
|
||||
for (a, b, ta, tb) in [(p, q, 2, 3), (q, r, 3, 4), (o1, p, 0, 2), (o1, r, 0, 4)] {
|
||||
push(&mut sk, ConstraintKind::Distance { p: a, q: b, value: truth[ta].dist(truth[tb]) });
|
||||
}
|
||||
push(&mut sk, ConstraintKind::Distance { p, q: r, value: truth[2].dist(truth[4]) });
|
||||
let m = if tangent {
|
||||
// Circle of radius 6 tangent to P R on Q's side, 25 from Q.
|
||||
let (a, b) = (truth[2], truth[4]);
|
||||
let d = (b - a).normalized().unwrap();
|
||||
let n = d.perp() * (d.cross(truth[3] - a).signum());
|
||||
let foot = a + d * 6.0 + n * 6.0;
|
||||
let k_at = {
|
||||
// On the offset line, 25 from Q.
|
||||
let rel = foot - truth[3];
|
||||
let t = -rel.dot(d) + (625.0 - rel.cross(d).powi(2)).sqrt();
|
||||
foot + d * t
|
||||
};
|
||||
let circle = sk.add_circle(k_at + v(0.5, 0.3), 6.0, None, None).unwrap();
|
||||
let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() };
|
||||
push(&mut sk, ConstraintKind::Radius { c: circle, value: 6.0 });
|
||||
push(&mut sk, ConstraintKind::Tangent { a: pr, b: circle });
|
||||
push(&mut sk, ConstraintKind::Distance { p: q, q: c, value: 25.0 });
|
||||
push(&mut sk, ConstraintKind::Distance { p: o2, q: c, value: truth[1].dist(k_at) });
|
||||
c
|
||||
} else {
|
||||
let m = sk.add_point((truth[2] + truth[4]) * 0.5 + v(0.4, 0.2), None).unwrap();
|
||||
push(&mut sk, ConstraintKind::Midpoint { p: m, l: pr });
|
||||
push(&mut sk, ConstraintKind::Distance { p: o2, q: m, value: truth[1].dist((truth[2] + truth[4]) * 0.5) });
|
||||
m
|
||||
};
|
||||
let out = seed_of(&sk);
|
||||
for x in [p, q, r, m] {
|
||||
assert_eq!(out.placed_by[x], Some(Placement::Cluster), "tangent {tangent}, point {x}: {out:?}");
|
||||
}
|
||||
let rep = solve(&mut sk);
|
||||
assert!(rep.ok() && rep.dof == 0 && rep.iterations == 0, "tangent {tangent}: {rep:?}");
|
||||
for (i, t) in [(p, 2), (q, 3), (r, 4)] {
|
||||
assert!(sk.points[i].pos.dist(truth[t]) < 1e-9, "tangent {tangent}: {:?}", sk.points[i].pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A circle's radius known from a dimension is part of the seed: the circle's radius starts
|
||||
/// there.
|
||||
#[test]
|
||||
fn a_dimensioned_radius_is_seeded() {
|
||||
let mut rnd = Lcg(17);
|
||||
let case = bolt_circle(&mut rnd, 2.0, 2.0).unwrap();
|
||||
let sk = edited(&case);
|
||||
let s = seed_of(&sk);
|
||||
let ConstraintKind::Diameter { c, value } = sk.constraints[1].kind else { panic!() };
|
||||
assert_eq!(s.radius[c], Some(value / 2.0));
|
||||
}
|
||||
|
||||
/// Hostile curve references and values in the constraints the new loci read neither panic
|
||||
/// nor change the report (every solve runs through the seed-on/seed-off comparison).
|
||||
#[test]
|
||||
fn hostile_lines_and_rounds_neither_panic_nor_change_the_report() {
|
||||
let base = || tangent_chain(&mut Lcg(21), 0.5, 2.0).unwrap();
|
||||
let raw = |sk: &mut Sketch, kind: ConstraintKind| {
|
||||
sk.constraints.push(Constraint { id: format!("h{}", sk.constraints.len()), kind, param: None, driven: false, text: None })
|
||||
};
|
||||
let cases: Vec<Box<dyn Fn(&mut Sketch)>> = vec![
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: 1, l: 999 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Midpoint { p: usize::MAX, l: 0 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Symmetric { p: 1, q: 2, l: usize::MAX })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 0, b: 0 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Tangent { a: 2, b: 77 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: f64::NAN })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Radius { c: 2, value: -3.0 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Diameter { c: 0, value: 1e300 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: 2 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::PointOnCurve { p: 1, c: usize::MAX })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::PointLineDistance { p: 1, l: 1, value: f64::INFINITY })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::LineAngle { l: 0, value: f64::NAN })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Angle { a: 0, b: 1, value: 1e300, flip: true })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Parallel { a: 0, b: 0 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Perpendicular { a: 0, b: 2 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Equal { a: 0, b: 2 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::Concentric { a: 2, b: 2 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::SlotOverallLength { a: 2, b: 2, value: 5.0 })),
|
||||
Box::new(|sk| raw(sk, ConstraintKind::LinearDiameter { p: 3, l: 0, value: -1.0 })),
|
||||
Box::new(|sk| sk.points[2].pos = v(f64::NAN, 0.0)),
|
||||
Box::new(|sk| sk.points[3].pos = v(1e300, -1e300)),
|
||||
];
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
let mut c = base();
|
||||
case(&mut c.sk);
|
||||
let sk = edited(&c);
|
||||
let s = seed(&sk, &given(&sk), Some(&[v(f64::NAN, 0.0)]), &points_on_curves(&sk), &mut Budget::full());
|
||||
assert!(s.pos.iter().flatten().all(|p| p.is_finite()), "case {i}: {s:?}");
|
||||
assert!(s.radius.iter().flatten().all(|r| r.is_finite() && *r > 0.0), "case {i}: {s:?}");
|
||||
let mut t = c.sk.clone();
|
||||
solve(&mut t);
|
||||
}
|
||||
}
|
||||
|
||||
/// An axis-aligned line angle turned half a turn (0° → 180°) with a line at an angle to it and
|
||||
/// a tangent arc: the explicit angle decides the direction, not the reference's axis sign, so
|
||||
/// the seed builds the chain forward along the new angle and nothing folds.
|
||||
#[test]
|
||||
fn an_axis_aligned_line_angle_turned_half_a_turn_is_built_along_it() {
|
||||
let case = chain(&mut Lcg(31), 1.0, 1.0, 0.0, std::f64::consts::PI).unwrap();
|
||||
let sk = edited(&case);
|
||||
let (i0, i1) = sk.curves[0].kind.ends().unwrap();
|
||||
let s = seed_of(&sk);
|
||||
let (p0, e1) = (sk.points[i0].pos, s.pos[i1].unwrap_or_else(|| panic!("L1's end unplaced: {s:?}")));
|
||||
assert!((e1 - p0).dot(Vec2::X) < 0.0, "L1 built along the reference's axis, not its angle: {p0:?} → {e1:?}");
|
||||
assert_eq!(run(&case, true), (false, false));
|
||||
}
|
||||
|
||||
/// Horizontal and vertical lines tied by a contradictory parallel (or a perpendicular between
|
||||
/// two horizontal lines) are still read as horizontal and vertical one by one: their rows and
|
||||
/// columns are seeded.
|
||||
#[test]
|
||||
fn contradictory_levels_still_read_each_line_on_its_own() {
|
||||
for perpendicular in [false, true] {
|
||||
let mut sk = Sketch::new();
|
||||
let l1 = sk.add_line(v(0.0, 0.0), v(40.0, 0.5), None, None, None).unwrap();
|
||||
let (a, b) = sk.curves[l1].kind.ends().unwrap();
|
||||
let l2 = if perpendicular {
|
||||
sk.add_line(v(0.0, 30.0), v(30.0, 30.4), None, None, None).unwrap()
|
||||
} else {
|
||||
sk.add_line(v(0.0, 30.0), v(0.3, 60.0), None, None, None).unwrap()
|
||||
};
|
||||
let (c, d) = sk.curves[l2].kind.ends().unwrap();
|
||||
for p in [a, c] {
|
||||
push(&mut sk, ConstraintKind::Fix { p });
|
||||
}
|
||||
push(&mut sk, ConstraintKind::Horizontal { l: l1 });
|
||||
push(&mut sk, ConstraintKind::Length { l: l1, value: 40.0 });
|
||||
push(&mut sk, if perpendicular { ConstraintKind::Horizontal { l: l2 } } else { ConstraintKind::Vertical { l: l2 } });
|
||||
push(&mut sk, ConstraintKind::Length { l: l2, value: 30.0 });
|
||||
push(&mut sk, if perpendicular { ConstraintKind::Perpendicular { a: l1, b: l2 } } else { ConstraintKind::Parallel { a: l1, b: l2 } });
|
||||
let s = seed_of(&sk);
|
||||
assert!(s.pos[b].is_some_and(|p| p.dist(v(40.0, 0.0)) < 1e-9), "perpendicular {perpendicular}: {s:?}");
|
||||
let want = if perpendicular { v(30.0, 30.0) } else { v(0.0, 60.0) };
|
||||
assert!(s.pos[d].is_some_and(|p| p.dist(want) < 1e-9), "perpendicular {perpendicular}: {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimensioned circle's radius is seeded also when nothing is given to build on.
|
||||
#[test]
|
||||
fn a_dimensioned_radius_is_seeded_with_nothing_given() {
|
||||
let mut sk = Sketch::new();
|
||||
let circle = sk.add_circle(v(10.0, 5.0), 10.0, None, None).unwrap();
|
||||
push(&mut sk, ConstraintKind::Diameter { c: circle, value: 30.0 });
|
||||
let s = seed(&sk, &vec![false; sk.points.len()], None, &[], &mut Budget::full());
|
||||
assert_eq!(s.radius[circle], Some(15.0));
|
||||
}
|
||||
|
||||
/// A line's end constrained onto a circle is the tangency's touch point only through the
|
||||
/// solve's point-on-curve index: with it the circle's centre is placed on the normal there,
|
||||
/// without it only a touching pair of loci is left.
|
||||
#[test]
|
||||
fn the_touch_point_comes_from_the_point_on_curve_index() {
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(v(0.0, 0.0), v(40.0, 12.0), None, None, None).unwrap();
|
||||
let (a, b) = sk.curves[l].kind.ends().unwrap();
|
||||
let d = (v(40.0, 12.0) - v(0.0, 0.0)).normalized().unwrap();
|
||||
let circle = sk.add_circle(v(40.0, 12.0) + d.perp() * 8.5, 8.0, None, None).unwrap();
|
||||
let CurveKind::Circle { c, .. } = sk.curves[circle].kind else { panic!() };
|
||||
push(&mut sk, ConstraintKind::Fix { p: a });
|
||||
push(&mut sk, ConstraintKind::LineAngle { l, value: 12f64.atan2(40.0) });
|
||||
push(&mut sk, ConstraintKind::Length { l, value: v(40.0, 12.0).len() });
|
||||
push(&mut sk, ConstraintKind::Radius { c: circle, value: 8.0 });
|
||||
push(&mut sk, ConstraintKind::Tangent { a: l, b: circle });
|
||||
push(&mut sk, ConstraintKind::PointOnCurve { p: b, c: circle });
|
||||
let on_curve = crate::solver::points_on_curves(&sk);
|
||||
let with = seed(&sk, &given(&sk), None, &on_curve, &mut Budget::full());
|
||||
assert!(with.placed_by[c].is_some_and(Placement::anchors), "{with:?}");
|
||||
assert!(with.pos[c].unwrap().dist(v(40.0, 12.0) + d.perp() * 8.0) < 1e-9, "{with:?}");
|
||||
let without = seed(&sk, &given(&sk), None, &[], &mut Budget::full());
|
||||
assert!(!without.placed_by[c].is_some_and(Placement::anchors), "{without:?}");
|
||||
}
|
||||
|
||||
/// Many pair clusters each grown by one shared point: a fixed point on every line from another
|
||||
/// fixed point to a point at a distance from it. Each cluster grows the shared point and then
|
||||
/// merges with the ground, and every growth requeues the clusters holding that point, so the
|
||||
/// steps outnumber any multiple of the starting clusters: the merge loop is bounded by its
|
||||
/// work budget, not by a count of steps, and places every point.
|
||||
#[test]
|
||||
fn long_growth_chains_are_not_starved() {
|
||||
let (o, u) = (v(5.0, -3.0), Vec2::from_angle(0.4));
|
||||
let mut sk = Sketch::new();
|
||||
let p = sk.add_point(o, None).unwrap();
|
||||
let z = sk.add_point(o + u * 12.0, None).unwrap();
|
||||
for x in [p, z] {
|
||||
push(&mut sk, ConstraintKind::Fix { p: x });
|
||||
}
|
||||
push(&mut sk, ConstraintKind::Distance { p, q: z, value: 12.0 });
|
||||
let qs: Vec<usize> = (0..40)
|
||||
.map(|j| {
|
||||
let d = 20.0 + 3.0 * j as f64;
|
||||
let q = sk.add_point(o + u * d + v(0.5, -0.4), None).unwrap();
|
||||
let l = sk.add_line_pts(p, q, None).unwrap();
|
||||
push(&mut sk, ConstraintKind::Distance { p, q, value: d });
|
||||
push(&mut sk, ConstraintKind::PointOnCurve { p: z, c: l });
|
||||
q
|
||||
})
|
||||
.collect();
|
||||
let s = seed_of(&sk);
|
||||
for (j, q) in qs.iter().enumerate() {
|
||||
assert_eq!(s.placed_by[*q], Some(Placement::Cluster), "point {j}: {s:?}");
|
||||
assert!(s.pos[*q].is_some_and(|at| at.dist(o + u * (20.0 + 3.0 * j as f64)) < 1e-9), "point {j}: {s:?}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
//! Dimension continuation (`solve_after_value_edit`): the triconnected linkage family (two
|
||||
//! triangles joined by three links, left to the iteration by the construction) under random
|
||||
//! dimension edits from its previous solved state, with every failure that remains proven
|
||||
//! infeasible; and the solve without continuation when nothing it could help with changed.
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::construct::switch;
|
||||
use crate::model::{ConstraintKind, Sketch};
|
||||
use crate::solver::{DrivingValues, solve, solve_after_value_edit};
|
||||
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + self.next() * (hi - lo)
|
||||
}
|
||||
}
|
||||
|
||||
/// The linkage's solved layout: base triangle 0 1 2 (0 fixed, 0–1 horizontal), top triangle
|
||||
/// 3 4 5, links 0–3, 1–4, 2–5.
|
||||
const LAYOUT: [(f64, f64); 6] = [(0.0, 0.0), (40.0, 0.0), (18.0, 30.0), (8.0, 55.0), (52.0, 48.0), (30.0, 80.0)];
|
||||
const PAIRS: [(usize, usize); 9] = [(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (0, 3), (1, 4), (2, 5)];
|
||||
|
||||
/// The linkage in its solved layout with every distance edited by `lo..hi`×, and the values
|
||||
/// the layout was solved for (per constraint). With `lines`, each triangle side is a line of
|
||||
/// its own (end points coincident with the corners) with a length dimension.
|
||||
fn linkage(rng: &mut Lcg, lo: f64, hi: f64, lines: bool) -> (Sketch, DrivingValues) {
|
||||
let mut sk = Sketch::new();
|
||||
let ids: Vec<usize> = LAYOUT.iter().map(|p| sk.add_point(Vec2::new(p.0, p.1), None).unwrap()).collect();
|
||||
sk.add_constraint(ConstraintKind::Fix { p: ids[0] }, None).unwrap();
|
||||
let l = sk.add_line_pts(ids[0], ids[1], None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
|
||||
for (k, (a, b)) in PAIRS.into_iter().enumerate() {
|
||||
let (pa, pb) = (sk.points[ids[a]].pos, sk.points[ids[b]].pos);
|
||||
if lines && k < 6 {
|
||||
let l = sk.add_line(pa, pb, None, None, None).unwrap();
|
||||
let (s, e) = sk.curves[l].kind.ends().unwrap();
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: s, q: ids[a] }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Coincident { p: e, q: ids[b] }, None).unwrap();
|
||||
sk.add_constraint(ConstraintKind::Length { l, value: pa.dist(pb) }, None).unwrap();
|
||||
} else {
|
||||
sk.add_constraint(ConstraintKind::Distance { p: ids[a], q: ids[b], value: pa.dist(pb) }, None).unwrap();
|
||||
}
|
||||
}
|
||||
let old = sk.driving_values();
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(v) = c.kind.value() {
|
||||
c.kind.set_value(v * rng.range(lo, hi));
|
||||
}
|
||||
}
|
||||
(sk, old)
|
||||
}
|
||||
|
||||
/// The nine distances of a linkage sketch, in `PAIRS` order.
|
||||
fn distances(sk: &Sketch) -> Vec<f64> {
|
||||
sk.constraints.iter().filter_map(|c| c.kind.value()).collect()
|
||||
}
|
||||
|
||||
/// Turn of both triangles (layout point `i` is sketch point `i + 1`, after the origin).
|
||||
fn turns(sk: &Sketch) -> [bool; 2] {
|
||||
let p = |i: usize| sk.points[i + 1].pos;
|
||||
[(p(1) - p(0)).cross(p(2) - p(0)) > 0.0, (p(4) - p(3)).cross(p(5) - p(3)) > 0.0]
|
||||
}
|
||||
|
||||
/// Both intersections of two circles; `None` when they miss by more than rounding.
|
||||
fn circles(c0: Vec2, r0: f64, c1: Vec2, r1: f64) -> Option<[Vec2; 2]> {
|
||||
let d = c0.dist(c1);
|
||||
let a = (r0 * r0 - r1 * r1 + d * d) / (2.0 * d);
|
||||
let h2 = r0 * r0 - a * a;
|
||||
if h2 < -1e-9 * r0 * r0 {
|
||||
return None;
|
||||
}
|
||||
let u = (c1 - c0) / d;
|
||||
let m = c0 + u * a;
|
||||
let h = h2.max(0.0).sqrt();
|
||||
Some([m + u.perp() * h, m - u.perp() * h])
|
||||
}
|
||||
|
||||
/// Whether the linkage with distances `d` (`PAIRS` order) has a real solution, decided by
|
||||
/// branch and bound. With the base triangle placed (one of its four mirror images) and the
|
||||
/// top triangle's shape fixed (one of two), a solution is an angle φ of link 0–3 and a turn θ
|
||||
/// of the top triangle where the links 1–4 and 2–5 have their lengths. Over a cell of
|
||||
/// half-width w in (φ, θ) the points move at most (d03 + top radius)·w, so a cell whose centre
|
||||
/// misses both lengths by more than that holds no solution: when every cell is discarded so,
|
||||
/// none exists. A cell that cannot be discarded down to w ≈ 1e-10 holds one within rounding.
|
||||
/// `keep`: only solutions with both triangles turning so. `near`: only solutions with every
|
||||
/// point within the distance of the given layout (discarded the same way).
|
||||
fn feasible(d: &[f64], keep: Option<[bool; 2]>, near: Option<(&[Vec2], f64)>) -> bool {
|
||||
let [d01, d12, d20, d34, d45, d53, d03, d14, d25] = d.try_into().unwrap();
|
||||
let Some(tops) = circles(Vec2::ZERO, d53, Vec2::new(d34, 0.0), d45) else { return false };
|
||||
let q4 = Vec2::new(d34, 0.0);
|
||||
let lip = d03 + d34.max(d53);
|
||||
let far = |i: usize, at: Vec2, slack: f64| near.is_some_and(|(layout, r)| layout[i].dist(at) > r + slack);
|
||||
for s1 in [1.0, -1.0] {
|
||||
let p1 = Vec2::new(s1 * d01, 0.0);
|
||||
let Some(p2s) = circles(Vec2::ZERO, d20, p1, d12) else { return false };
|
||||
for p2 in p2s.into_iter().filter(|p2| keep.is_none_or(|k| (p1.cross(*p2) > 0.0) == k[0])) {
|
||||
if far(1, p1, 0.0) || far(2, p2, 0.0) {
|
||||
continue;
|
||||
}
|
||||
for q5 in tops.into_iter().filter(|q5| keep.is_none_or(|k| (q4.cross(*q5) > 0.0) == k[1])) {
|
||||
let miss = |phi: f64, theta: f64, w: f64| {
|
||||
let rot = |q: Vec2| Vec2::new(q.x * theta.cos() - q.y * theta.sin(), q.x * theta.sin() + q.y * theta.cos());
|
||||
let p3 = Vec2::from_angle(phi) * d03;
|
||||
let (p4, p5) = (p3 + rot(q4), p3 + rot(q5));
|
||||
if far(3, p3, lip * w) || far(4, p4, lip * w) || far(5, p5, lip * w) {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
(p1.dist(p4) - d14).abs().max((p2.dist(p5) - d25).abs())
|
||||
};
|
||||
let n = 64;
|
||||
let w0 = std::f64::consts::PI / n as f64;
|
||||
let mut stack: Vec<(f64, f64, f64)> = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
stack.push(((2 * i + 1) as f64 * w0, (2 * j + 1) as f64 * w0, w0));
|
||||
}
|
||||
}
|
||||
while let Some((phi, theta, w)) = stack.pop() {
|
||||
if miss(phi, theta, w) > lip * w * (1.0 + 1e-9) + 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if w < 1e-10 {
|
||||
return true;
|
||||
}
|
||||
let h = w / 2.0;
|
||||
for (a, b) in [(-h, -h), (-h, h), (h, -h), (h, h)] {
|
||||
stack.push((phi + a, theta + b, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The edit's distances `t` of the way from old to new.
|
||||
fn along(old: &[f64], new: &[f64], t: f64) -> Vec<f64> {
|
||||
old.iter().zip(new).map(|(o, n)| o + t * (n - o)).collect()
|
||||
}
|
||||
|
||||
/// Outcome counts over the family.
|
||||
#[derive(Debug, Default)]
|
||||
struct Count {
|
||||
one_jump_failed: usize,
|
||||
one_jump_folded: usize,
|
||||
/// Turned a triangle over (named in the report, for a value edit to refuse).
|
||||
refused: usize,
|
||||
/// Failed otherwise.
|
||||
failed: usize,
|
||||
/// Of the refused and failed: the new values have no solution.
|
||||
infeasible: usize,
|
||||
/// The new values are solved only with a triangle mirrored.
|
||||
only_mirrored: usize,
|
||||
/// The sketch's own solution ends on the straight value path: followed in 1/4096 steps
|
||||
/// it fails or folds at some t < 1, and no orientation-keeping solution for the values at
|
||||
/// t lies within 1 mm of where it was one step before (a fold of the path, or no solution).
|
||||
branch_ends: usize,
|
||||
/// Refused or failed cases none of the above explains: old and new distances.
|
||||
unexplained: Vec<(Vec<f64>, Vec<f64>)>,
|
||||
}
|
||||
|
||||
/// The linkage's distances set to `d`.
|
||||
fn set_distances(sk: &mut Sketch, d: &[f64]) {
|
||||
for (c, v) in sk.constraints.iter_mut().filter(|c| c.kind.value().is_some()).zip(d) {
|
||||
c.kind.set_value(*v);
|
||||
}
|
||||
}
|
||||
|
||||
/// The value path from `old` to `new` in 1/4096 steps, each solved by the iteration alone
|
||||
/// from the previous solution: where it fails or folds a triangle (t and the layout one step
|
||||
/// before), or `None` when it reaches the new values.
|
||||
fn fine_path(sk: &Sketch, old: &[f64], new: &[f64]) -> Option<(f64, Vec<Vec2>)> {
|
||||
let mut s = sk.clone();
|
||||
for (p, xy) in s.points.iter_mut().skip(1).zip(LAYOUT) {
|
||||
p.pos = Vec2::new(xy.0, xy.1);
|
||||
}
|
||||
let before = turns(&s);
|
||||
let n = 4096;
|
||||
for k in 1..=n {
|
||||
let t = k as f64 / n as f64;
|
||||
let last: Vec<Vec2> = s.points.iter().skip(1).map(|p| p.pos).collect();
|
||||
set_distances(&mut s, &along(old, new, t));
|
||||
let rep = switch::without_differential(|| switch::without_seed(|| solve(&mut s)));
|
||||
if !rep.ok() || turns(&s) != before {
|
||||
return Some((t, last));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `n` edits of the family: the one-jump solve (LM from the layout) against
|
||||
/// `solve_after_value_edit`, which must solve keeping both triangles' turn or refuse with the
|
||||
/// layout kept; every refusal and failure is explained (see [`Count`]).
|
||||
fn run_family(seed: u64, n: usize, lo: f64, hi: f64, lines: bool) -> Count {
|
||||
let mut rng = Lcg(seed);
|
||||
let mut count = Count::default();
|
||||
for _ in 0..n {
|
||||
let (sk, old) = linkage(&mut rng, lo, hi, lines);
|
||||
let before = turns(&sk);
|
||||
let (mut jump, mut cont) = (sk.clone(), sk.clone());
|
||||
let (a, b) = switch::without_differential(|| (solve(&mut jump), solve_after_value_edit(&mut cont, &old)));
|
||||
count.one_jump_failed += usize::from(!a.ok());
|
||||
count.one_jump_folded += usize::from(a.ok() && turns(&jump) != before);
|
||||
if a.ok() && turns(&jump) == before {
|
||||
assert_eq!(jump.points, cont.points, "a one-jump solve that keeps its triangles is the result");
|
||||
}
|
||||
if b.keeps_orientation() {
|
||||
assert_eq!(turns(&cont), before, "an accepted edit keeps every triangle's turn");
|
||||
continue;
|
||||
}
|
||||
if b.folded.is_empty() {
|
||||
count.failed += 1;
|
||||
assert_eq!(cont.points, sk.points, "a failed solve keeps the layout");
|
||||
} else {
|
||||
// Solved, turned over: kept as the best result, named for a command to refuse.
|
||||
count.refused += 1;
|
||||
assert!(b.ok() && turns(&cont) != before, "{b:?}");
|
||||
let dims: Vec<&str> = sk.constraints.iter().filter(|c| c.kind.value().is_some()).map(|c| c.id.as_str()).collect();
|
||||
assert!(b.folded.iter().all(|id| dims.contains(&id.as_str()) && b.failing.contains(id)), "{b:?}");
|
||||
}
|
||||
let new = distances(&sk);
|
||||
let old: Vec<f64> = sk.constraints.iter().filter_map(|c| old.get(&c.id).copied()).collect();
|
||||
if !feasible(&new, None, None) {
|
||||
count.infeasible += 1;
|
||||
} else if !feasible(&new, Some(before), None) {
|
||||
count.only_mirrored += 1;
|
||||
} else if let Some((t, last)) = fine_path(&sk, &old, &new)
|
||||
&& !feasible(&along(&old, &new, t), Some(before), Some((&last, 1.0)))
|
||||
{
|
||||
count.branch_ends += 1;
|
||||
} else {
|
||||
count.unexplained.push((old, new));
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn report(name: &str, c: &Count) {
|
||||
eprintln!(
|
||||
"{name}: one jump failed {} folded {}; continuation refused {} failed {} (infeasible {}, only mirrored {}, branch ends {}, unexplained {})",
|
||||
c.one_jump_failed,
|
||||
c.one_jump_folded,
|
||||
c.refused,
|
||||
c.failed,
|
||||
c.infeasible,
|
||||
c.only_mirrored,
|
||||
c.branch_ends,
|
||||
c.unexplained.len()
|
||||
);
|
||||
for u in c.unexplained.iter().take(3) {
|
||||
eprintln!(" {u:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feasibility_check_agrees_with_the_layout() {
|
||||
let d: Vec<f64> = PAIRS.iter().map(|&(a, b)| Vec2::new(LAYOUT[a].0, LAYOUT[a].1).dist(Vec2::new(LAYOUT[b].0, LAYOUT[b].1))).collect();
|
||||
assert!(feasible(&d, None, None));
|
||||
let mut long = d.clone();
|
||||
long[7] = 500.0;
|
||||
assert!(!feasible(&long, None, None));
|
||||
let mut flat = d;
|
||||
flat[0] = flat[1] + flat[2] + 1.0;
|
||||
assert!(!feasible(&flat, None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linkage_edits_are_solved_by_continuation_or_refused_with_reason() {
|
||||
// The first 40 edits of the two families of `linkage_family_counter`, with every refusal
|
||||
// and failure explained.
|
||||
let c = run_family(2026, 40, 0.8, 1.25, false);
|
||||
report("linkage 0.8–1.25× (40)", &c);
|
||||
assert!(c.unexplained.is_empty() && c.branch_ends > 0 && c.failed + c.refused < c.one_jump_failed + c.one_jump_folded, "{c:?}");
|
||||
let c = run_family(7, 40, 0.5, 2.5, true);
|
||||
report("linkage 0.5–2.5× lines (40)", &c);
|
||||
assert!(c.unexplained.is_empty() && c.refused > 0, "{c:?}");
|
||||
}
|
||||
|
||||
/// Measurement, not a gate: the two families, 500 edits each, every refusal and failure
|
||||
/// explained (run with `--ignored --nocapture`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn linkage_family_counter() {
|
||||
for (seed, lo, hi, lines) in [(2026, 0.8, 1.25, false), (7, 0.5, 2.5, true)] {
|
||||
let c = run_family(seed, 500, lo, hi, lines);
|
||||
report(&format!("linkage {lo}–{hi}× lines {lines} (500)"), &c);
|
||||
assert!(c.unexplained.is_empty(), "{c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_from_an_unsolved_state_are_not_continued() {
|
||||
// Old values the layout is not solved for: no solved state to step from, nor orientations
|
||||
// to keep; the one-jump result stands.
|
||||
let mut rng = Lcg(2026);
|
||||
let mut tried = 0;
|
||||
for _ in 0..200 {
|
||||
let (sk, old) = linkage(&mut rng, 0.8, 1.25, false);
|
||||
let wrong: DrivingValues = old.iter().map(|(k, v)| (k.clone(), v * 1.1)).collect();
|
||||
let (mut a, mut b) = (sk.clone(), sk.clone());
|
||||
let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, &wrong));
|
||||
if ra.ok() {
|
||||
continue;
|
||||
}
|
||||
tried += 1;
|
||||
assert_eq!(rb.continuations, 0, "{rb:?}");
|
||||
assert!(rb.folded.is_empty());
|
||||
assert_eq!(a.points, b.points);
|
||||
}
|
||||
assert!(tried > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_values_solve_as_without_old_values() {
|
||||
let mut rng = Lcg(1);
|
||||
for _ in 0..20 {
|
||||
let (sk, _) = linkage(&mut rng, 0.8, 1.25, true);
|
||||
let current = sk.driving_values();
|
||||
let (mut a, mut b) = (sk.clone(), sk.clone());
|
||||
let (ra, rb) = (solve(&mut a), solve_after_value_edit(&mut b, ¤t));
|
||||
assert_eq!((ra.status, ra.iterations, ra.dof), (rb.status, rb.iterations, rb.dof));
|
||||
assert_eq!(rb.continuations, 0);
|
||||
assert_eq!(a.points, b.points);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_old_values_never_panic() {
|
||||
let mut rng = Lcg(3);
|
||||
let (sk, old) = linkage(&mut rng, 0.5, 2.5, false);
|
||||
let all = |v: f64| -> DrivingValues { old.keys().map(|k| (k.clone(), v)).collect() };
|
||||
let unknown: DrivingValues = (0..20).map(|i| (format!("nope{i}"), 1.0)).collect();
|
||||
for bad in [DrivingValues::new(), all(f64::NAN), all(f64::INFINITY), all(-1e300), all(0.0), unknown, old.clone()] {
|
||||
let mut s = sk.clone();
|
||||
let rep = solve_after_value_edit(&mut s, &bad);
|
||||
assert!(rep.max_residual.is_finite());
|
||||
assert!(s.points.iter().all(|p| p.pos.x.is_finite() && p.pos.y.is_finite()));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::model::{ConstraintKind, CurveKind, Sketch};
|
||||
use crate::union_find::{find, union};
|
||||
|
||||
/// What a dimension measures, in a form that can be drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -138,30 +139,14 @@ pub fn encode_text(f: &DimFrame, at: Vec2) -> Vec2 {
|
||||
pub fn chain_centres(sk: &Sketch) -> Vec<Vec2> {
|
||||
let n = sk.points.len();
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
fn root(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
break;
|
||||
}
|
||||
let g = p.get(q).copied().unwrap_or(q);
|
||||
if let Some(s) = p.get_mut(i) {
|
||||
*s = g;
|
||||
}
|
||||
i = q;
|
||||
}
|
||||
i
|
||||
}
|
||||
for c in &sk.curves {
|
||||
let ids = c.kind.point_ids();
|
||||
for w in ids.windows(2) {
|
||||
let (a, b) = (root(&mut parent, w[0]), root(&mut parent, w[1]));
|
||||
if let Some(s) = parent.get_mut(a) {
|
||||
*s = b;
|
||||
}
|
||||
union(&mut parent, w[0], w[1]);
|
||||
}
|
||||
}
|
||||
let mut sum = vec![(Vec2::ZERO, 0usize); n];
|
||||
let roots: Vec<usize> = (0..n).map(|i| root(&mut parent, i)).collect();
|
||||
let roots: Vec<usize> = (0..n).map(|i| find(&mut parent, i)).collect();
|
||||
for (i, r) in roots.iter().enumerate() {
|
||||
if let (Some(s), Some(p)) = (sum.get_mut(*r), sk.points.get(i)) {
|
||||
s.0 += p.pos;
|
||||
|
||||
@@ -5,14 +5,25 @@
|
||||
//! The solver is our own clean-room implementation (residual functions per constraint, local
|
||||
//! finite-difference Jacobians, damped Gauss–Newton per connected component, rank analysis by
|
||||
//! Gaussian elimination). See `plan/architecture.md` §sketch.
|
||||
//!
|
||||
//! Before iterating, a constructive seed places every point that two loci on already placed
|
||||
//! points pin down (circles, rows and columns, lines through placed points, offsets from
|
||||
//! placed lines, tangency, midpoints, mirrored points, coincidence), on the branch that keeps
|
||||
//! the orientation the point had before the solve, so dimension edits do not fold the sketch
|
||||
//! into a mirrored solution or flip an arc out of its corner. The seed only moves the starting
|
||||
//! point: the iteration still verifies every constraint and the rank analysis still decides
|
||||
//! the degrees of freedom.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod construct;
|
||||
mod curves;
|
||||
mod decompose;
|
||||
mod dims;
|
||||
mod freeform_offset;
|
||||
mod linalg;
|
||||
mod link;
|
||||
mod loci;
|
||||
mod model;
|
||||
mod offset;
|
||||
mod offset_topology;
|
||||
@@ -21,12 +32,14 @@ mod profiles;
|
||||
mod shape;
|
||||
mod solver;
|
||||
mod text;
|
||||
mod union_find;
|
||||
mod wire;
|
||||
|
||||
pub use curves::{
|
||||
MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent,
|
||||
spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
|
||||
};
|
||||
pub use decompose::Budget;
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES};
|
||||
@@ -36,10 +49,18 @@ pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRole
|
||||
pub use periodic::periodic_beziers;
|
||||
pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves};
|
||||
pub use shape::{Shape, intersections};
|
||||
pub use solver::{Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_holding};
|
||||
pub use solver::{
|
||||
Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with,
|
||||
};
|
||||
pub use text::{MAX_TEXT_CHARS, text_geometry};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
|
||||
#[cfg(test)]
|
||||
mod construct_rounds_tests;
|
||||
#[cfg(test)]
|
||||
mod construct_tests;
|
||||
#[cfg(test)]
|
||||
mod continuation_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError};
|
||||
use crate::model::{Curve, CurveKind, Result, Sketch, SketchError, in_range, radius_in_range};
|
||||
|
||||
/// Points of one link closer than this are merged into one point.
|
||||
const MERGE_TOL: f64 = 1e-7;
|
||||
@@ -154,16 +154,14 @@ pub enum LinkGeom {
|
||||
|
||||
impl LinkGeom {
|
||||
fn finite(&self) -> bool {
|
||||
let ok = |p: &Vec2| p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9;
|
||||
let ok = |p: &Vec2| in_range(*p);
|
||||
match self {
|
||||
LinkGeom::Point(p) => ok(p),
|
||||
LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL,
|
||||
LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9,
|
||||
LinkGeom::Circle(c, r) => ok(c) && radius_in_range(*r),
|
||||
LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL,
|
||||
LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6,
|
||||
LinkGeom::Ellipse { c, major, minor } => {
|
||||
ok(c) && ok(major) && c.dist(*major) > 1e-9 && minor.is_finite() && *minor > 1e-9 && *minor < 1e9
|
||||
}
|
||||
LinkGeom::Ellipse { c, major, minor } => ok(c) && ok(major) && c.dist(*major) > 1e-9 && radius_in_range(*minor),
|
||||
LinkGeom::Spline { pts, control, degree, periodic } => {
|
||||
(!*periodic || (!*control && *degree == 3 && crate::periodic_beziers(pts).is_ok()))
|
||||
&& (2..=crate::curves::MAX_SPLINE_POINTS).contains(&pts.len())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+101
-21
@@ -1,5 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Seg2, Vec2};
|
||||
use solvecraft_geom::{Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::union_find::{find, union};
|
||||
|
||||
/// Hard caps that keep hostile input from exhausting memory or time.
|
||||
pub const MAX_POINTS: usize = 20_000;
|
||||
@@ -7,6 +9,24 @@ pub const MAX_CONSTRAINTS: usize = 40_000;
|
||||
/// Curves need points, so this follows the point cap.
|
||||
pub const MAX_CURVES: usize = MAX_POINTS;
|
||||
|
||||
/// Largest coordinate or dimension value (mm) a sketch accepts.
|
||||
pub(crate) const MAX_COORD: f64 = 1e9;
|
||||
|
||||
/// A position a sketch accepts: finite and within [`MAX_COORD`].
|
||||
pub(crate) fn in_range(p: Vec2) -> bool {
|
||||
p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD
|
||||
}
|
||||
|
||||
/// [`in_range`] for a 3D position.
|
||||
pub(crate) fn in_range3(p: Vec3) -> bool {
|
||||
p.is_finite() && p.x.abs() < MAX_COORD && p.y.abs() < MAX_COORD && p.z.abs() < MAX_COORD
|
||||
}
|
||||
|
||||
/// A radius a sketch accepts: finite, positive and within [`MAX_COORD`].
|
||||
pub(crate) fn radius_in_range(r: f64) -> bool {
|
||||
r.is_finite() && r > 1e-9 && r < MAX_COORD
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SketchError {
|
||||
#[error("unknown sketch entity `{0}`")]
|
||||
@@ -367,7 +387,8 @@ impl ConstraintKind {
|
||||
pub fn refers_to_point(&self, p: usize) -> bool {
|
||||
self.indices().0.contains(&p)
|
||||
}
|
||||
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
/// The points and the curves the constraint refers to.
|
||||
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
|
||||
use ConstraintKind::*;
|
||||
match *self {
|
||||
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
|
||||
@@ -607,7 +628,7 @@ impl Sketch {
|
||||
}
|
||||
|
||||
fn check_pos(p: Vec2) -> Result<Vec2> {
|
||||
if p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
|
||||
if in_range(p) { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
|
||||
}
|
||||
|
||||
/// Add a free point (or reuse an existing one within `merge_tol` of `p` when `merge_tol > 0`).
|
||||
@@ -679,7 +700,7 @@ impl Sketch {
|
||||
}
|
||||
|
||||
pub fn add_circle(&mut self, center: Vec2, r: f64, c: Option<usize>, id: Option<&str>) -> Result<usize> {
|
||||
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
|
||||
if !radius_in_range(r) {
|
||||
return Err(SketchError::Invalid(format!("radius {r}")));
|
||||
}
|
||||
let id = self.curve_id(id, "c")?;
|
||||
@@ -703,7 +724,7 @@ impl Sketch {
|
||||
/// the circle through `p0`).
|
||||
pub fn add_arc(&mut self, center: Vec2, p0: Vec2, p1: Vec2, pts: [Option<usize>; 3], id: Option<&str>) -> Result<usize> {
|
||||
let r = center.dist(p0);
|
||||
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
|
||||
if !radius_in_range(r) {
|
||||
return Err(SketchError::Invalid("degenerate arc".into()));
|
||||
}
|
||||
let p1 = center + (p1 - center).normalized().ok_or_else(|| SketchError::Invalid("degenerate arc".into()))? * r;
|
||||
@@ -838,7 +859,7 @@ impl Sketch {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(v) = k.value()
|
||||
&& (!v.is_finite() || v.abs() > 1e9 || (v < 0.0 && !k.is_angle()))
|
||||
&& (!v.is_finite() || v.abs() > MAX_COORD || (v < 0.0 && !k.is_angle()))
|
||||
{
|
||||
return Err(SketchError::Invalid(format!("dimension value {v}")));
|
||||
}
|
||||
@@ -858,7 +879,7 @@ impl Sketch {
|
||||
|| self.curves.iter().any(|c| c.fixed && c.kind.uses(i))
|
||||
}
|
||||
|
||||
/// [`Sketch::point_locked`] for every point at once.
|
||||
/// [`Sketch::point_locked`] for every point at once: the points every solve keeps as they are.
|
||||
pub fn locked_points(&self) -> Vec<bool> {
|
||||
let mut out: Vec<bool> = self.points.iter().map(|p| p.fixed || p.link.is_some()).collect();
|
||||
let mut set = |i: usize| {
|
||||
@@ -887,21 +908,9 @@ impl Sketch {
|
||||
}
|
||||
// Points made one by coincident constraints.
|
||||
let mut root: Vec<usize> = (0..self.points.len()).collect();
|
||||
fn find(r: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&p) = r.get(i) {
|
||||
if p == i {
|
||||
break;
|
||||
}
|
||||
i = p;
|
||||
}
|
||||
i
|
||||
}
|
||||
for c in &self.constraints {
|
||||
if let ConstraintKind::Coincident { p, q } = c.kind {
|
||||
let (a, b) = (find(&mut root, p), find(&mut root, q));
|
||||
if let Some(x) = root.get_mut(a) {
|
||||
*x = b;
|
||||
}
|
||||
union(&mut root, p, q);
|
||||
}
|
||||
}
|
||||
let ends: Vec<Vec<usize>> =
|
||||
@@ -1007,6 +1016,77 @@ impl Sketch {
|
||||
out
|
||||
}
|
||||
|
||||
/// Every point a constraint touches: its points and the points of its curves.
|
||||
pub fn constraint_points(&self, k: &ConstraintKind) -> Vec<usize> {
|
||||
let (mut pts, curves) = k.indices();
|
||||
pts.extend(curves.into_iter().flat_map(|c| self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default()));
|
||||
pts
|
||||
}
|
||||
|
||||
/// What a solve holding `points` and the radii of `radii` moves with them, as the solver
|
||||
/// couples its variables: (per point, per curve radius). The held points, and the free
|
||||
/// points and free circle/ellipse radii joined to them through curves and driving
|
||||
/// constraints without passing through a constant (a held, fixed or projected point or
|
||||
/// radius couples nothing).
|
||||
pub fn coupled(&self, points: &[usize], radii: &[usize]) -> (Vec<bool>, Vec<bool>) {
|
||||
let (n, m) = (self.points.len(), self.curves.len());
|
||||
// Nodes: points, then curve radii, then one node standing for everything held.
|
||||
let held = n + m;
|
||||
let mut root: Vec<usize> = (0..=held).collect();
|
||||
let mut seed = vec![false; n + m];
|
||||
for v in points.iter().copied().chain(radii.iter().map(|c| n.saturating_add(*c))) {
|
||||
if let Some(s) = seed.get_mut(v) {
|
||||
*s = true;
|
||||
}
|
||||
}
|
||||
let locked = self.locked_points();
|
||||
let free = |v: usize| {
|
||||
if seed.get(v).copied().unwrap_or(true) {
|
||||
return false;
|
||||
}
|
||||
match v.checked_sub(n) {
|
||||
None => !locked.get(v).copied().unwrap_or(true),
|
||||
Some(c) => self
|
||||
.curves
|
||||
.get(c)
|
||||
.is_some_and(|x| matches!(x.kind, CurveKind::Circle { .. } | CurveKind::Ellipse { .. }) && x.link.is_none() && !x.fixed),
|
||||
}
|
||||
};
|
||||
let curve_nodes = |c: usize| {
|
||||
let mut v = self.curves.get(c).map(|x| x.kind.point_ids()).unwrap_or_default();
|
||||
v.push(n + c);
|
||||
v
|
||||
};
|
||||
// What the solver's equations join (see `solve_with`): an arc's centre and ends, a
|
||||
// periodic spline's first and last points, and each driving constraint's entities. A
|
||||
// curve alone joins nothing (a circle's radius and centre move independently).
|
||||
let own = self.curves.iter().filter_map(|x| match &x.kind {
|
||||
CurveKind::Arc { c, a, b } => Some(vec![*c, *a, *b]),
|
||||
CurveKind::Spline { pts, periodic: true, .. } => match (pts.first(), pts.last()) {
|
||||
(Some(a), Some(b)) if a != b => Some(vec![*a, *b]),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
});
|
||||
let edges = own.chain(self.constraints.iter().filter(|c| !c.driven).map(|c| {
|
||||
let (mut v, curves) = c.kind.indices();
|
||||
v.extend(curves.into_iter().flat_map(curve_nodes));
|
||||
v
|
||||
}));
|
||||
for e in edges {
|
||||
let mut join = e.iter().copied().filter(|&v| free(v));
|
||||
let first = if e.iter().any(|v| seed.get(*v).copied().unwrap_or(false)) { Some(held) } else { join.next() };
|
||||
if let Some(first) = first {
|
||||
for v in join {
|
||||
union(&mut root, first, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = find(&mut root, held);
|
||||
let mut coupled = |v: usize| seed.get(v).copied().unwrap_or(false) || (free(v) && find(&mut root, v) == r);
|
||||
((0..n).map(&mut coupled).collect(), (n..n + m).map(&mut coupled).collect())
|
||||
}
|
||||
|
||||
/// Is curve `c` held in place: fixed or projected (linked).
|
||||
pub fn curve_locked(&self, c: usize) -> bool {
|
||||
self.curves.get(c).is_some_and(|c| c.fixed || c.link.is_some() || self.freeform_offsets.iter().any(|offset| offset.output == c.id))
|
||||
@@ -1139,7 +1219,7 @@ impl Sketch {
|
||||
CurveKind::Circle { .. } => "c",
|
||||
CurveKind::Arc { .. } => "a",
|
||||
CurveKind::Ellipse { m, c, r } => {
|
||||
if m == c || !(r.is_finite() && *r > 1e-9 && *r < 1e9) {
|
||||
if m == c || !radius_in_range(*r) {
|
||||
return Err(SketchError::Invalid("degenerate ellipse".into()));
|
||||
}
|
||||
"e"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Explicit cyclic C2 fit splines. Missing periodic markers retain natural interpolation.
|
||||
use crate::model::in_range;
|
||||
use crate::{MAX_SPLINE_POINTS, Sketch, SketchError};
|
||||
use solvecraft_geom::Vec2;
|
||||
|
||||
@@ -11,7 +12,7 @@ pub(crate) fn validate_periodic_points(points: &[Vec2]) -> Result<()> {
|
||||
if points.len() < 4
|
||||
|| points.len() > MAX_SPLINE_POINTS
|
||||
|| points.first() != points.last()
|
||||
|| points.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9)
|
||||
|| points.iter().any(|p| !in_range(*p))
|
||||
|| points.windows(2).any(|p| {
|
||||
let h = p[0].dist(p[1]);
|
||||
!h.is_finite() || h <= 1e-9
|
||||
@@ -89,7 +90,7 @@ pub fn periodic_beziers(points: &[Vec2]) -> Result<Vec<[Vec2; 4]>> {
|
||||
let t0 = chord - (second[i] * 2.0 + second[next]) * (h[i] * h[i] / 6.0);
|
||||
let t1 = chord + (second[i] + second[next] * 2.0) * (h[i] * h[i] / 6.0);
|
||||
let cubic = [points[i], points[i] + t0 / 3.0, points[i + 1] - t1 / 3.0, points[i + 1]];
|
||||
if cubic.iter().any(|p| !p.is_finite() || p.x.abs() >= 1e9 || p.y.abs() >= 1e9) {
|
||||
if cubic.iter().any(|p| !in_range(*p)) {
|
||||
return Err(bad());
|
||||
}
|
||||
out.push(cubic);
|
||||
@@ -162,7 +163,7 @@ mod tests {
|
||||
sk.add_curve(crate::CurveKind::Spline { pts: vec![first, a, b, seam], control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
sk.add_curve(crate::CurveKind::Spline { pts: vec![seam, c, d, last], control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
sk.points[first].pos = Vec2::new(0.2, 0.1);
|
||||
assert!(crate::solve_holding(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }).ok());
|
||||
assert!(crate::solve_with(&mut sk, &crate::Hold { points: vec![first], radii: vec![] }, None, crate::Analysis::Full, None).ok());
|
||||
assert_eq!(sk.point(first), sk.point(seam));
|
||||
assert_eq!(sk.point(first), sk.point(last));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
@@ -176,7 +177,7 @@ mod tests {
|
||||
let mut collapsed = sk;
|
||||
collapsed.points[a].pos = collapsed.points[first].pos;
|
||||
let before = collapsed.clone();
|
||||
assert!(!crate::solve_holding(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }).ok());
|
||||
assert!(!crate::solve_with(&mut collapsed, &crate::Hold { points: vec![first, a], radii: vec![] }, None, crate::Analysis::Full, None).ok());
|
||||
assert_eq!(collapsed, before);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
|
||||
|
||||
use crate::model::{CurveKind, Sketch};
|
||||
use crate::shape::{Shape, intersections};
|
||||
use crate::union_find::{find, union};
|
||||
|
||||
const MERGE_TOL: f64 = 1e-6;
|
||||
const MAX_EDGES: usize = 50_000;
|
||||
@@ -399,16 +400,6 @@ impl Grid {
|
||||
}
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
break;
|
||||
}
|
||||
i = q;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Signed curvature at the start of a segment (left turns positive).
|
||||
fn curvature(s: &Seg2) -> f64 {
|
||||
match *s {
|
||||
@@ -756,12 +747,7 @@ fn find_profiles_bounded(sk: &Sketch, max_edges: usize) -> Vec<Profile> {
|
||||
let mut comp: Vec<usize> = (0..n).collect();
|
||||
for (e, a) in edges.iter().zip(&alive) {
|
||||
if *a {
|
||||
let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v));
|
||||
if ru != rv
|
||||
&& let Some(s) = comp.get_mut(rv)
|
||||
{
|
||||
*s = ru;
|
||||
}
|
||||
union(&mut comp, e.v, e.u);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1227
-195
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,44 @@ use solvecraft_geom::Vec2;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec2 {
|
||||
pub(crate) fn v(x: f64, y: f64) -> Vec2 {
|
||||
Vec2::new(x, y)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers for the solver harnesses.
|
||||
pub(crate) struct Lcg(pub u64);
|
||||
|
||||
impl Lcg {
|
||||
pub(crate) fn next(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(self.0 >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + self.next() * (hi - lo)
|
||||
}
|
||||
/// ±1, evenly.
|
||||
pub(crate) fn sign(&mut self) -> f64 {
|
||||
if self.next() < 0.5 { 1.0 } else { -1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with the constructive seed on or off, without the seed-on/seed-off comparison (for
|
||||
/// tests that measure the difference).
|
||||
pub(crate) fn seeded<T>(on: bool, f: impl FnOnce() -> T) -> T {
|
||||
use crate::construct::switch;
|
||||
switch::without_differential(|| if on { f() } else { switch::without_seed(f) })
|
||||
}
|
||||
|
||||
/// The points that are constants for a plain solve.
|
||||
pub(crate) fn given(sk: &Sketch) -> Vec<bool> {
|
||||
crate::solver::held_points(sk, &Hold::default())
|
||||
}
|
||||
|
||||
/// The constructive seed of a plain solve.
|
||||
pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed {
|
||||
crate::construct::seed(sk, &given(sk), None, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full())
|
||||
}
|
||||
|
||||
/// Rough rectangle from four lines sharing corner points.
|
||||
fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] {
|
||||
let a = sk.add_point(p[0], None).unwrap();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Disjoint sets over indices (`parent[i]` is `i` for a root), shared by the solver's
|
||||
//! components, profile regions, the model's point groups and dimension placement's chains.
|
||||
|
||||
/// The root of `i`'s set, halving the path on the way. An index outside `parent` is its own
|
||||
/// root.
|
||||
pub(crate) fn find(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&p) = parent.get(i) {
|
||||
if p == i {
|
||||
break;
|
||||
}
|
||||
let gp = parent.get(p).copied().unwrap_or(p);
|
||||
if let Some(slot) = parent.get_mut(i) {
|
||||
*slot = gp;
|
||||
}
|
||||
i = p;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Join the sets of `a` and `b`.
|
||||
pub(crate) fn union(parent: &mut [usize], a: usize, b: usize) {
|
||||
let (ra, rb) = (find(parent, a), find(parent, b));
|
||||
if let Some(slot) = parent.get_mut(ra) {
|
||||
*slot = rb;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn joins_and_halves_paths() {
|
||||
let mut p: Vec<usize> = (0..6).collect();
|
||||
union(&mut p, 0, 1);
|
||||
union(&mut p, 1, 2);
|
||||
union(&mut p, 4, 5);
|
||||
assert_eq!(find(&mut p, 0), find(&mut p, 2));
|
||||
assert_ne!(find(&mut p, 0), find(&mut p, 4));
|
||||
assert_eq!(find(&mut p, 3), 3);
|
||||
assert_eq!(find(&mut p, 99), 99);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
|
||||
use crate::link::{Link, LinkGeom, LinkKind, LinkSource};
|
||||
use crate::model::{Result, Sketch, SketchError};
|
||||
use crate::model::{Result, Sketch, SketchError, in_range3};
|
||||
|
||||
/// Most points one wire may have.
|
||||
pub const MAX_WIRE_POINTS: usize = 100_000;
|
||||
@@ -97,7 +97,7 @@ fn clean(w: &[Vec3]) -> Option<Vec<Vec3>> {
|
||||
}
|
||||
let mut out: Vec<Vec3> = Vec::new();
|
||||
for p in w {
|
||||
if !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9) {
|
||||
if !in_range3(*p) {
|
||||
return None;
|
||||
}
|
||||
if out.last().is_none_or(|l| l.dist(*p) > 1e-9) {
|
||||
@@ -118,14 +118,14 @@ fn drawn_polyline(fit: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
if fit.is_empty() || fit.len() > MAX_FIT_POINTS {
|
||||
return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points")));
|
||||
}
|
||||
if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
if fit.iter().any(|p| !in_range3(*p)) {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) {
|
||||
return Err(SketchError::Invalid("the points of a 3D curve must differ".into()));
|
||||
}
|
||||
let pts = fit_polyline(fit);
|
||||
if pts.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
if pts.iter().any(|p| !in_range3(*p)) {
|
||||
return Err(SketchError::Invalid("3D spline samples out of range".into()));
|
||||
}
|
||||
Ok(pts)
|
||||
@@ -163,7 +163,7 @@ impl Sketch {
|
||||
|| w.pts.len() > MAX_WIRE_POINTS
|
||||
|| w.fit.len() > MAX_FIT_POINTS
|
||||
|| (w.pts.len() == 1 && w.fit.len() != 1)
|
||||
|| w.pts.iter().chain(&w.fit).any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9))
|
||||
|| w.pts.iter().chain(&w.fit).any(|p| !in_range3(*p))
|
||||
{
|
||||
return Err(SketchError::Invalid("3D curve arrays, ids or point bounds are invalid".into()));
|
||||
}
|
||||
@@ -241,7 +241,7 @@ impl Sketch {
|
||||
/// Move a fit point of a drawn 3D curve, and every other drawn point at the same place
|
||||
/// (curves drawn end to end stay joined). Returns how many points moved.
|
||||
pub fn move_drawn_point(&mut self, wire: &str, index: usize, to: Vec3) -> Result<usize> {
|
||||
if !(to.is_finite() && to.x.abs() < 1e9 && to.y.abs() < 1e9 && to.z.abs() < 1e9) {
|
||||
if !in_range3(to) {
|
||||
return Err(SketchError::Invalid("3D point out of range".into()));
|
||||
}
|
||||
let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?;
|
||||
|
||||
@@ -193,7 +193,8 @@ pub fn picks(app: &SolveApp, command: &str, params: &Value) -> Vec<Vec3> {
|
||||
// A profile feature without picks takes the profiles of the active (else the last) sketch:
|
||||
// point at them.
|
||||
if out.is_empty() && matches!(id, "solid.extrude" | "solid.revolve") {
|
||||
let ss = app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()));
|
||||
let ss =
|
||||
app.session.active_sketch.and_then(|s| st.sketch(s)).or_else(|| st.sketches.iter().rev().find(|s| !s.profiles.is_empty()).map(|s| &**s));
|
||||
if let Some(ss) = ss {
|
||||
out.extend(ss.profiles.iter().take(4).map(|p| ss.plane.to_world(p.region.interior_point())));
|
||||
}
|
||||
|
||||
@@ -69,9 +69,9 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let offset = if start.trim().is_empty() { 0.0 } else { app.session.doc.eval(start, ValueKind::Length).ok()? };
|
||||
(at + n * (from + offset), Some(n))
|
||||
}
|
||||
Kind::Fillet { .. } | Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
|
||||
// Shell thickness grows into the body.
|
||||
Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||
Kind::Section { .. } | Kind::Hole { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
|
||||
// Fillet radii, chamfer distances and shell thickness grow into the body.
|
||||
Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
|
||||
Kind::Plastic(_) => (at, normal),
|
||||
Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)),
|
||||
@@ -98,8 +98,8 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solvecraft_
|
||||
Some(solvecraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
|
||||
}
|
||||
|
||||
/// The outward direction between the two faces at an edge (where a fillet's radius arrow
|
||||
/// points).
|
||||
/// The outward direction between the two faces at an edge (a fillet's radius arrow points the
|
||||
/// other way, into the body).
|
||||
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
let faces = m.edge_faces.get(index)?;
|
||||
let mut sum = Vec3::ZERO;
|
||||
|
||||
@@ -1326,7 +1326,7 @@ fn track_hold(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
i.time,
|
||||
)
|
||||
});
|
||||
let free = app.tool.is_none() && app.dialog.is_none();
|
||||
let free = app.tool.is_none() && app.dialog.is_none() && app.preferences.nav != crate::prefs::OPENSCAD_NAV;
|
||||
if pressed && free && app.viewport.context_menu.is_none() && app.viewport.mouse.is_some() {
|
||||
app.menu.press = pos.map(|p| (p, time));
|
||||
}
|
||||
|
||||
@@ -2243,15 +2243,30 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
let id = egui::Id::new(("pexpr", &p.name));
|
||||
let mut e = ui.data(|dd| dd.get_temp::<String>(id)).unwrap_or_else(|| p.expr.clone());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e).desired_width(160.0));
|
||||
let refused_id = egui::Id::new(("prefused", &p.name));
|
||||
if r.changed() {
|
||||
ui.data_mut(|dd| dd.insert_temp(id, e.clone()));
|
||||
// Typing again clears the reason a previous change was refused.
|
||||
ui.data_mut(|dd| {
|
||||
dd.insert_temp(id, e.clone());
|
||||
dd.remove::<String>(refused_id);
|
||||
});
|
||||
}
|
||||
if r.lost_focus() && e != p.expr {
|
||||
let _ = app.run("parameters.change", json!({"name": p.name, "expression": e}));
|
||||
ui.data_mut(|dd| dd.remove::<String>(id));
|
||||
match change_param(app, &p.name, &e) {
|
||||
Ok(()) => ui.data_mut(|dd| {
|
||||
dd.remove::<String>(id);
|
||||
dd.remove::<String>(refused_id);
|
||||
}),
|
||||
// Refused (it would leave a sketch unsolved, say): the typed expression
|
||||
// stays in the field with the reason beside it, nothing silently reverts.
|
||||
Err(why) => ui.data_mut(|dd| {
|
||||
dd.insert_temp(refused_id, why);
|
||||
}),
|
||||
}
|
||||
}
|
||||
let refused = ui.data(|dd| dd.get_temp::<String>(refused_id));
|
||||
let v = vals.get(&p.name).map(|v| if p.unit == "deg" { format!("{:.3}°", v.v.to_degrees()) } else { format!("{:.4} {}", v.v, p.unit) });
|
||||
match (v, errs.get(&p.name)) {
|
||||
match (v, errs.get(&p.name).or(refused.as_ref())) {
|
||||
(_, Some(e)) => ui.label(RichText::new(e).color(t.error)),
|
||||
(Some(v), None) => ui.label(RichText::new(v).color(t.text_dim)),
|
||||
_ => ui.label(""),
|
||||
@@ -2273,6 +2288,14 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
/// Set a parameter's expression from the parameters table: `Err` with the message to show
|
||||
/// beside the field when the change is refused (the document keeps its values).
|
||||
pub(crate) fn change_param(app: &mut SolveApp, name: &str, expression: &str) -> Result<(), String> {
|
||||
app.run("parameters.change", json!({"name": name, "expression": expression}))
|
||||
.map(|_| ())
|
||||
.map_err(|e| crate::friendly_error("parameters.change", &e))
|
||||
}
|
||||
|
||||
/// Start a sketch on a picked plane or planar face and turn the view to face it.
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
|
||||
let plane = match sel {
|
||||
@@ -3751,3 +3774,30 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
d.error = None;
|
||||
Some(d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod param_refusal_tests {
|
||||
use super::*;
|
||||
use solvecraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn a_refused_parameter_change_shows_why() {
|
||||
// A triangle whose sides are parameters: a side longer than the other two together
|
||||
// has no solution, so the change is refused and the table shows the reason.
|
||||
let mut s = Session::default();
|
||||
s.execute("sketch.create", &json!({"plane": "XY", "name": "Tri"})).unwrap();
|
||||
s.execute("sketch.line", &json!({"points": [[0, 0], [40, 0], [20, 30]], "closed": true})).unwrap();
|
||||
for l in ["l1", "l2", "l3"] {
|
||||
s.execute("sketch.dimension", &json!({"entities": [l]})).unwrap();
|
||||
}
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let name = s.doc.params.iter().find(|p| p.model).map(|p| p.name.clone()).unwrap();
|
||||
let before = s.doc.param(&name).map(|p| p.expr.clone());
|
||||
let mut app = SolveApp::new(s, Default::default());
|
||||
let why = change_param(&mut app, &name, "500 mm").unwrap_err();
|
||||
assert!(why.contains("Tri") && why.contains("conflict"), "{why}");
|
||||
assert_eq!(app.session.doc.param(&name).map(|p| p.expr.clone()), before);
|
||||
assert!(app.status.as_ref().is_some_and(|(m, _, error)| *error && m.contains("Tri")), "{:?}", app.status);
|
||||
assert!(change_param(&mut app, &name, "41 mm").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ struct DimensionDraft {
|
||||
preview_doc: Arc<solvecraft_engine::doc::Document>,
|
||||
preview: Arc<solvecraft_engine::doc::ModelState>,
|
||||
error: Option<String>,
|
||||
/// A dimension that would over-constrain the sketch, placed as a driven (reference) one:
|
||||
/// it has no parameter and no value to edit (#320).
|
||||
driven: bool,
|
||||
}
|
||||
|
||||
impl DimensionDraft {
|
||||
@@ -102,8 +105,15 @@ pub fn cancel() {
|
||||
|
||||
/// Infer the measured value through the same command as scripts, without creating a real
|
||||
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
|
||||
pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
|
||||
/// A driven dimension has no value to accept, so it is added at once.
|
||||
pub fn begin_dimension(app: &mut SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
|
||||
let (param, expr, draft) = dimension_draft(app, entities, at)?;
|
||||
if draft.driven {
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
app.run("sketch.dimension", json!({"entities": draft.entities, "text_at": [at.x, at.y], "driven": true}))?;
|
||||
app.set_status("That dimension would over-constrain the sketch, so it was added as a driven (reference) dimension", false);
|
||||
return Ok(());
|
||||
}
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
Ok(())
|
||||
@@ -114,22 +124,41 @@ fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(S
|
||||
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
|
||||
return Err("Pick one edge or two endpoints, then place the dimension".into());
|
||||
}
|
||||
// A dimension the sketch can't take (it would over-constrain it) becomes a driven one,
|
||||
// which only measures; when that fails too, the first refusal is the one to report.
|
||||
draft_of(app, sketch, &entities, at, false).or_else(|error| draft_of(app, sketch, &entities, at, true).map_err(|_| error))
|
||||
}
|
||||
|
||||
/// The draft of one dimension: its parameter (for a driven one, its constraint id, since it
|
||||
/// has no parameter), the parameter's expression, and the scratch session it was added in.
|
||||
fn draft_of(app: &SolveApp, sketch: u64, entities: &[String], at: Vec2, driven: bool) -> Result<(String, String, DimensionDraft), String> {
|
||||
let mut scratch = app.session.scratch();
|
||||
let result = scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y]})).map_err(|e| e.to_string())?;
|
||||
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
|
||||
let expr = scratch.doc.param(¶m).ok_or("The dimension value is unavailable")?.expr.clone();
|
||||
let result =
|
||||
scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y], "driven": driven})).map_err(|e| e.to_string())?;
|
||||
let (param, expr) = if driven {
|
||||
let before =
|
||||
app.session.doc.sketch(sketch).ok().map(|sk| sk.constraints.iter().map(|c| c.id.clone()).collect::<Vec<_>>()).unwrap_or_default();
|
||||
let added =
|
||||
scratch.doc.sketch(sketch).ok().and_then(|sk| sk.constraints.iter().find(|c| c.driven && !before.contains(&c.id)).map(|c| c.id.clone()));
|
||||
(added.ok_or("The driven dimension is unavailable")?, String::new())
|
||||
} else {
|
||||
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
|
||||
let expr = scratch.doc.param(¶m).ok_or("The dimension value is unavailable")?.expr.clone();
|
||||
(param, expr)
|
||||
};
|
||||
let draft = DimensionDraft {
|
||||
baseline: app.session.doc.clone(),
|
||||
sketch,
|
||||
component: app.session.active_component,
|
||||
path: app.session.active_occurrence_path.clone(),
|
||||
pending: app.session.pending_moves.clone(),
|
||||
entities,
|
||||
entities: entities.to_vec(),
|
||||
at,
|
||||
checked_expr: expr.clone(),
|
||||
preview: scratch.world_state(),
|
||||
preview_doc: scratch.doc.clone(),
|
||||
error: None,
|
||||
driven,
|
||||
};
|
||||
if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) {
|
||||
return Err("That dimension conflicts with the sketch constraints".into());
|
||||
@@ -258,9 +287,10 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
}
|
||||
|
||||
/// Start dragging the text of the dimension under `press` (true: a dimension took the drag).
|
||||
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2) -> bool {
|
||||
/// Its lines take it only off sketch geometry (`on_geometry`: a point or curve is under `press`).
|
||||
pub fn drag_start(app: &SolveApp, press: Pos2, press_sketch: Vec2, on_geometry: bool) -> bool {
|
||||
let Some(d) = DRAWN.with(|d| {
|
||||
let id = hit(press)?;
|
||||
let id = hit_text(press).or_else(|| if on_geometry { None } else { hit(press) })?;
|
||||
d.borrow().iter().find(|x| x.id == id).cloned()
|
||||
}) else {
|
||||
return false;
|
||||
@@ -367,7 +397,9 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
let draft_centres = draft_sketch.map(|solved| chain_centres(&solved.sketch));
|
||||
let mut constraints = sk.constraints.iter().map(|c| (ss, c, false)).collect::<Vec<_>>();
|
||||
if let (Some(source), Some((param, _))) = (draft_sketch, draft.as_ref()) {
|
||||
constraints.extend(source.sketch.constraints.iter().filter(|c| c.param.as_deref() == Some(param.as_str())).map(|c| (source, c, true)));
|
||||
// A driven draft is keyed by its constraint id (it has no parameter).
|
||||
let ours = |c: &&solvecraft_engine::sketch::Constraint| c.param.as_deref() == Some(param.as_str()) || (c.driven && c.id == *param);
|
||||
constraints.extend(source.sketch.constraints.iter().filter(ours).map(|c| (source, c, true)));
|
||||
}
|
||||
for (source, c, is_draft) in constraints {
|
||||
if ss.sketch.view.hide_dimensions && !is_draft {
|
||||
@@ -604,6 +636,35 @@ mod tests {
|
||||
cancel();
|
||||
}
|
||||
|
||||
/// Placing a dimension the sketch can't take previews it as driven: in the draft it is a
|
||||
/// driven constraint with no parameter, labelled in parentheses (#320).
|
||||
#[test]
|
||||
fn an_over_constraining_placement_previews_a_driven_dimension() {
|
||||
cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm"})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
h.step(&json!({"click":{"sketch":[20,10]}})).unwrap();
|
||||
h.step(&json!({"move":{"sketch":[20,20]}})).unwrap();
|
||||
h.settle();
|
||||
let (id, draft) = placement_preview(&h.app).expect("an over-constraining dimension still previews");
|
||||
assert!(draft.driven);
|
||||
let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch;
|
||||
let c = sk.constraints.iter().find(|c| c.id == id).unwrap();
|
||||
assert!(c.driven && c.param.is_none(), "{c:?}");
|
||||
assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6));
|
||||
assert_eq!(label(&c.kind, None, c.driven, "mm", 2), "(20.00)");
|
||||
assert!(!editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_follows_real_pointer_without_editing_or_mutating_design() {
|
||||
cancel();
|
||||
|
||||
@@ -323,3 +323,58 @@ fn inch_dimension_entry_survives_unit_changes_undo_and_reopen() {
|
||||
assert_eq!(reopened.world_state().sketch(sketch).unwrap().sketch, before);
|
||||
crate::dim_view::cancel();
|
||||
}
|
||||
|
||||
/// Placing the dimension of two unaligned points above them gives the horizontal distance,
|
||||
/// beside them the vertical one, and across from them the distance between them (#257).
|
||||
#[test]
|
||||
fn placing_a_two_point_dimension_picks_horizontal_vertical_or_aligned() {
|
||||
// What each placement fixes at 10 mm: the X span, the Y span, or the length.
|
||||
let spans = |a: solvecraft_engine::geom::Vec2, b: solvecraft_engine::geom::Vec2| [(b.x - a.x).abs(), (b.y - a.y).abs(), a.dist(b)];
|
||||
for (at, measured) in [([6., 25.], 0), ([-10., 1.], 1), ([-5., 12.], 2)] {
|
||||
let mut h = line([12., 16.]);
|
||||
place(&mut h, &[[0., 0.], [12., 16.]], at);
|
||||
type_value(&mut h, "10 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap();
|
||||
let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap();
|
||||
let spans = spans(a, b);
|
||||
assert!((spans[measured] - 10.0).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}");
|
||||
// A horizontal or vertical dimension leaves the other span as drawn.
|
||||
if measured < 2 {
|
||||
assert!((spans[1 - measured] - [12.0, 16.0][1 - measured]).abs() < 1e-6, "placed at {at:?}: {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimension that would over-constrain the sketch is placed as a driven (reference) one
|
||||
/// instead of failing: added with no parameter or value editor, and removed again by one
|
||||
/// undo (#320).
|
||||
#[test]
|
||||
fn an_over_constraining_dimension_is_placed_as_a_driven_one() {
|
||||
let mut h = line([20., 0.]);
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"20 mm"})).unwrap();
|
||||
h.settle();
|
||||
let original = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let params = h.app.session.doc.params.len();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
|
||||
h.step(&json!({"move":{"world":[10,6,0]}})).unwrap();
|
||||
h.settle();
|
||||
assert_eq!(crate::dim_view::drawn().len(), 1, "the preview is not a selectable dimension");
|
||||
h.step(&json!({"click":{"world":[10,6,0]}})).unwrap();
|
||||
assert!(!crate::dim_view::editing(), "a driven dimension has no value to edit");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert_eq!(h.app.session.doc.params.len(), params, "a driven dimension adds no parameter");
|
||||
let state = h.app.session.world_state();
|
||||
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert!(solved.report.ok());
|
||||
let dims: Vec<_> = solved.sketch.constraints.iter().filter(|c| c.kind.is_dimension()).collect();
|
||||
assert_eq!(dims.len(), 2);
|
||||
assert_eq!(dims.iter().filter(|c| c.driven && c.param.is_none()).count(), 1, "{dims:?}");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use egui::{Align2, FontId, Shape, Stroke};
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
use solvecraft_engine::sketch::{CurveKind, Sketch};
|
||||
|
||||
@@ -195,18 +195,18 @@ pub fn moving_points(sk: &Sketch, entity: &str) -> (Vec<usize>, Vec<usize>) {
|
||||
(own, ex)
|
||||
}
|
||||
|
||||
/// The drag let go: the constraint of the snap it ended on (none for lining up).
|
||||
pub fn release(app: &mut SolveApp) {
|
||||
let Some(s) = current() else { return };
|
||||
/// The drag let go: the constraint of the snap it ended on (none for lining up), as
|
||||
/// `sketch.drag_end` takes it.
|
||||
pub fn release() -> Option<Value> {
|
||||
let s = current()?;
|
||||
clear();
|
||||
let (cmd, params) = match &s.target {
|
||||
Target::Point(t) => ("sketch.constraint.coincident", json!({"a": s.point, "b": t})),
|
||||
Target::Mid(l) => ("sketch.constraint.midpoint", json!({"point": s.point, "line": l})),
|
||||
Target::On(c) => ("sketch.constraint.coincident", json!({"a": s.point, "b": c})),
|
||||
Target::Align => return,
|
||||
Target::Align => return None,
|
||||
};
|
||||
// A snap that would over-constrain the sketch is simply not kept.
|
||||
let _ = app.session.execute(cmd, ¶ms);
|
||||
Some(json!({"command": cmd, "params": params}))
|
||||
}
|
||||
|
||||
/// The snap of the drag in progress: its dashed guides, a ring on the target and its label.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user