Compare commits
170
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb9c81069 | ||
|
|
cedb51b8ea | ||
|
|
9d82b94469 | ||
|
|
a9beb437b3 | ||
|
|
db1fd39139 | ||
|
|
07da7f8065 | ||
|
|
7f79443b46 | ||
|
|
9bb65b4d68 | ||
|
|
18ec44d773 | ||
|
|
9422853cf9 | ||
|
|
f57a62deb7 | ||
|
|
61678654c2 | ||
|
|
25c89a706e | ||
|
|
26bf1af93b | ||
|
|
3707a67cee | ||
|
|
571122c4ba | ||
|
|
dcffab09b1 | ||
|
|
6f270f6259 | ||
|
|
dc6eca6830 | ||
|
|
d29ca29578 | ||
|
|
5e9285ca75 | ||
|
|
371578a06f | ||
|
|
44968dbfe6 | ||
|
|
3333f8a62b | ||
|
|
42d4ebeb16 | ||
|
|
d624e0a171 | ||
|
|
3fe929e91c | ||
|
|
c419dec76c | ||
|
|
f91103c51d | ||
|
|
ac3a25fb5b | ||
|
|
8f5073ff14 | ||
|
|
c5b7009ce3 | ||
|
|
f98c5e350d | ||
|
|
2e5b7b11e4 | ||
|
|
6c1a898e3d | ||
|
|
4d862cd76c | ||
|
|
baf44657c1 | ||
|
|
fdb059cfed | ||
|
|
716107f0e4 | ||
|
|
6220d8f8a6 | ||
|
|
c01a7b9b37 | ||
|
|
556e424bb2 | ||
|
|
ce96e625b4 | ||
|
|
5cfcf7fd03 | ||
|
|
fac3f96bdd | ||
|
|
77bdcc47e9 | ||
|
|
987f0be21e | ||
|
|
bd17eeef0c | ||
|
|
e21c5f06ac | ||
|
|
904bf19e83 | ||
|
|
7ef78f960a | ||
|
|
a422fb1a4b | ||
|
|
daf618c344 | ||
|
|
5165e428cd | ||
|
|
1a0b33fcf3 | ||
|
|
48322f6464 | ||
|
|
5c0351b1f4 | ||
|
|
b20fd3d9d6 | ||
|
|
63accec16e | ||
|
|
d188eea1f4 | ||
|
|
2141a3802a | ||
|
|
723c7b61c4 | ||
|
|
1a55241882 | ||
|
|
b562ab51e1 | ||
|
|
105a3146c0 | ||
|
|
f08ed74617 | ||
|
|
342305705d | ||
|
|
a6742180a9 | ||
|
|
abb15e4b0e | ||
|
|
584bbe923d | ||
|
|
9a07932f20 | ||
|
|
067f2642c2 | ||
|
|
af771b9ceb | ||
|
|
4cbf9cf607 | ||
|
|
c361630954 | ||
|
|
845f4913cf | ||
|
|
78242d67d1 | ||
|
|
2b45635d73 | ||
|
|
4097ac0131 | ||
|
|
96d1ad0ab2 | ||
|
|
3d914c9f15 | ||
|
|
1b5c2b5847 | ||
|
|
7160ff7beb | ||
|
|
b400958c3e | ||
|
|
1c9d6617ad | ||
|
|
f7967b1ff6 | ||
|
|
a519f3cf74 | ||
|
|
b44ada89e5 | ||
|
|
6128a20c5f | ||
|
|
73493b94c3 | ||
|
|
061c0f3333 | ||
|
|
3dd8e2d176 | ||
|
|
66d13059dd | ||
|
|
800ea40c0d | ||
|
|
20442eecd3 | ||
|
|
a13124acdd | ||
|
|
5823900439 | ||
|
|
22d1cd7032 | ||
|
|
6a0dc18655 | ||
|
|
e006dbf1af | ||
|
|
8241a750fa | ||
|
|
843c548589 | ||
|
|
7934edcad4 | ||
|
|
b56648c055 | ||
|
|
6b30326e6d | ||
|
|
39f6d68a50 | ||
|
|
5fbbc13885 | ||
|
|
b6806b16cf | ||
|
|
e20cf1858a | ||
|
|
6ad391fd75 | ||
|
|
2d069ca186 | ||
|
|
02fe495d38 | ||
|
|
1e984c8e46 | ||
|
|
a47e82ba94 | ||
|
|
be985f136d | ||
|
|
e8e2ccc8a9 | ||
|
|
4e10c31fdd | ||
|
|
3047aa60cd | ||
|
|
f0c5bfcdf2 | ||
|
|
d8a088f46a | ||
|
|
17b48d9e7e | ||
|
|
56747edb46 | ||
|
|
aa31e56477 | ||
|
|
ef53bc46f3 | ||
|
|
8b7fa2602e | ||
|
|
fd422a3fc1 | ||
|
|
e382a77350 | ||
|
|
a18bedddf6 | ||
|
|
29e72806f6 | ||
|
|
fb55c1341a | ||
|
|
d4ab9545a1 | ||
|
|
c2ba3657f3 | ||
|
|
d0a4443939 | ||
|
|
2bfec256b7 | ||
|
|
e8c340c59d | ||
|
|
20a7dd8764 | ||
|
|
3fa8fbd162 | ||
|
|
27529d9742 | ||
|
|
d43c414b7b | ||
|
|
8b97b9ed62 | ||
|
|
4c7ea34a68 | ||
|
|
b156b5e09c | ||
|
|
4823c293dd | ||
|
|
762b6fe934 | ||
|
|
073acd295b | ||
|
|
1744fbb262 | ||
|
|
41779cf7bb | ||
|
|
c36a6f9f90 | ||
|
|
6eaf7458ab | ||
|
|
ad30a2350e | ||
|
|
4b3b48388c | ||
|
|
0d99d33c38 | ||
|
|
bbfad166b0 | ||
|
|
e95e32d72a | ||
|
|
6fa9e0d2dd | ||
|
|
8a6332be83 | ||
|
|
4674f0a61c | ||
|
|
6fbb1c5d91 | ||
|
|
d05fe3d4de | ||
|
|
56622e4be8 | ||
|
|
4dc638109b | ||
|
|
d7a4a29ce3 | ||
|
|
9fbbf8dc3a | ||
|
|
5fa8e7d60a | ||
|
|
b01b9689c8 | ||
|
|
4426da6f5d | ||
|
|
5ead82e627 | ||
|
|
c29b0d2f13 | ||
|
|
480fd8d517 | ||
|
|
4c09fa5355 |
No files matched your search
@@ -3,7 +3,8 @@
|
||||
|
||||
[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`.
|
||||
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
|
||||
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
|
||||
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
|
||||
default-filter = """
|
||||
not (
|
||||
@@ -16,6 +17,8 @@ not (
|
||||
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
|
||||
))
|
||||
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
|
||||
| (package(solidcraft-kernel) & binary(boolean_props))
|
||||
| (package(solidcraft-engine) & binary(atomic_commands))
|
||||
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
# Fast checks always run; native builds run for code/CI changes.
|
||||
# PRs test what they touch; main and nightly test everything. On a pull request the "Select
|
||||
# checks" job maps the changed files to workspace packages (scripts/ci/affected.py), adds the
|
||||
# packages that depend on them, and Clippy, the test archive and the test shards cover only
|
||||
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
|
||||
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
|
||||
# pushes to main, the nightly run and Run workflow.
|
||||
# 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.
|
||||
@@ -49,6 +55,8 @@ jobs:
|
||||
outputs:
|
||||
native: ${{ steps.paths.outputs.native }}
|
||||
kernel: ${{ steps.paths.outputs.kernel }}
|
||||
full: ${{ steps.paths.outputs.full }}
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -62,6 +70,8 @@ jobs:
|
||||
run: |
|
||||
native=false
|
||||
kernel=false
|
||||
full=true
|
||||
packages=
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
else
|
||||
@@ -76,9 +86,20 @@ jobs:
|
||||
*) native=true ;;
|
||||
esac
|
||||
done < "$RUNNER_TEMP/ci-paths"
|
||||
# Pull requests build and test the touched packages and their dependents.
|
||||
python3 -I scripts/ci/affected.py --paths "$RUNNER_TEMP/ci-paths" > "$RUNNER_TEMP/ci-affected"
|
||||
cat "$RUNNER_TEMP/ci-affected"
|
||||
full=$(sed -n 's/^full=//p' "$RUNNER_TEMP/ci-affected")
|
||||
packages=$(sed -n 's/^packages=//p' "$RUNNER_TEMP/ci-affected")
|
||||
# Code changed but nothing was selected: be safe and test everything.
|
||||
if [ "$native" = true ] && [ "$full" != true ] && [ -z "$packages" ]; then full=true; fi
|
||||
fi
|
||||
echo "native=$native" >> "$GITHUB_OUTPUT"
|
||||
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "native=$native"
|
||||
echo "kernel=$kernel"
|
||||
echo "full=$full"
|
||||
echo "packages=$packages"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
@@ -102,6 +123,10 @@ jobs:
|
||||
run: cargo xtask layers
|
||||
- name: Web app offline use and updates (headless Chrome)
|
||||
run: cargo xtask web-offline-test
|
||||
- name: Affected-package selection self-test
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Claim bot tests
|
||||
run: node --test scripts/claim/*.test.js
|
||||
|
||||
clippy:
|
||||
name: Native Linux Clippy
|
||||
@@ -117,7 +142,15 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
- name: Clippy
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests lint the affected packages (scripts/ci/affected.py); everything else, all.
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
@@ -144,7 +177,14 @@ jobs:
|
||||
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
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
run: |
|
||||
# Pull requests archive only the affected packages (scripts/ci/affected.py).
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo nextest archive "${scope[@]}" --cargo-profile ci --locked --archive-file tests.tar.zst
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
@@ -164,8 +204,18 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
env:
|
||||
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
|
||||
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
|
||||
# would skip for want of an adapter fails instead.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
@@ -179,11 +229,15 @@ jobs:
|
||||
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
|
||||
# The boolean property tests draw new random inputs every run; they run only in the
|
||||
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
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[@]}"
|
||||
--profile ci --partition "slice:$SHARD/4" --no-tests=pass "${all[@]}"
|
||||
- name: Robustness goldens sample (pull requests that touch the kernel)
|
||||
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
|
||||
# the nightly run every seed in the step above.
|
||||
@@ -267,6 +321,33 @@ jobs:
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
|
||||
|
||||
property-tests:
|
||||
# The boolean property tests and the atomic command tests with many cases. Pull requests skip
|
||||
# them (.config/nextest.toml); pushes to main run atomic_commands with small defaults (seeds
|
||||
# 0..4) and skip boolean_props, whose random inputs would fail main at random.
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
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: Boolean properties and atomic commands
|
||||
# The defaults (8 cases, seeds 0..4) take 159 s and 802 s on a 2-core VM; four times the
|
||||
# cases and twice the seeds are estimated at about 40 minutes there (not yet timed on CI).
|
||||
env:
|
||||
PROPTEST_CASES: "32"
|
||||
ATOMIC_SEEDS: "0..8"
|
||||
run: |
|
||||
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
|
||||
--profile ci --ignore-default-filter --success-output final -E "binary(boolean_props) | binary(atomic_commands)"
|
||||
|
||||
doc-tests:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
@@ -326,7 +407,8 @@ jobs:
|
||||
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
|
||||
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
|
||||
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
|
||||
# results with the CPU.
|
||||
# results with the CPU, and draw headless pictures with the viewport's pipelines there
|
||||
# (crates/gpu/tests/capture.rs).
|
||||
windows-fxc:
|
||||
name: Windows D3D12 FXC shaders
|
||||
needs: changes
|
||||
@@ -336,6 +418,8 @@ jobs:
|
||||
env:
|
||||
WGPU_BACKEND: dx12
|
||||
WGPU_DX12_COMPILER: fxc
|
||||
# WARP is a software (Cpu) adapter.
|
||||
SOLIDCRAFT_GPU: software
|
||||
SOLIDCRAFT_REQUIRE_GPU: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -365,12 +449,15 @@ jobs:
|
||||
with:
|
||||
key: windows-startup
|
||||
- name: Backend order and probing on Windows
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
|
||||
- name: Build the app
|
||||
run: cargo build -p solidcraft --profile ci --locked
|
||||
- name: Start the app under different settings
|
||||
shell: pwsh
|
||||
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
|
||||
- name: Resize border from the first frame
|
||||
shell: pwsh
|
||||
run: tools/windows-resize-smoke.ps1 -Exe target/ci/solidcraft.exe
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# `/claim` and `/unclaim` on issues (CONTRIBUTING.md, "Sending a change"), and a daily sweep that
|
||||
# frees claims nobody is working on. The logic is in scripts/claim/claim.js (tested in ci.yml).
|
||||
name: Claim
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
schedule:
|
||||
- cron: '17 5 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/claim
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
# One run at a time per issue, so two simultaneous /claim comments can't both win.
|
||||
concurrency:
|
||||
group: claim-${{ github.event.issue.number || 'sweep' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
(!github.event.issue.pull_request && github.event.comment.user.type != 'Bot' &&
|
||||
(startsWith(github.event.comment.body, '/claim') || startsWith(github.event.comment.body, '/unclaim')))
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/claim
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const claim = require('./scripts/claim/claim.js');
|
||||
if (context.eventName === 'issue_comment') {
|
||||
await claim.handleComment({ github, context });
|
||||
} else {
|
||||
const released = await claim.sweep({ github, context });
|
||||
core.info(`Released: ${released.join(', ') || 'none'}`);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# Files generated from the code are rewritten here, on main, after each merge, so pull requests
|
||||
# never edit them (two pull requests that each regenerated one would always conflict):
|
||||
# THIRD-PARTY-LICENSES.txt (`cargo xtask licences`) and docs/features.md (`cargo xtask features`).
|
||||
name: Generated files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# One run at a time; a burst of merges leaves one run waiting, which sees all of them.
|
||||
concurrency:
|
||||
group: generated-files
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate and commit
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
# Always main itself, never pull request code, since this job can push.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: generated-files
|
||||
- name: THIRD-PARTY-LICENSES.txt
|
||||
run: cargo xtask licences
|
||||
- name: docs/features.md
|
||||
run: cargo xtask features
|
||||
- name: Commit if changed
|
||||
run: |
|
||||
if git diff --quiet -- THIRD-PARTY-LICENSES.txt docs/features.md; then echo "unchanged"; exit 0; fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add THIRD-PARTY-LICENSES.txt docs/features.md
|
||||
git commit -m "Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci]"
|
||||
for _ in 1 2 3; do
|
||||
git pull --rebase origin main && git push origin HEAD:main && exit 0
|
||||
sleep 10
|
||||
done
|
||||
exit 1
|
||||
@@ -0,0 +1,32 @@
|
||||
# Files many pull requests add to are kept so that additions don't conflict: one file per change
|
||||
# or one sorted line per item (CONTRIBUTING.md, "Lists many changes add to"). Fast, no build.
|
||||
name: Lists
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format-changes:
|
||||
name: Design format changes merge cleanly
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- run: bash scripts/test-format-pending.sh
|
||||
|
||||
sorted:
|
||||
name: Lists are sorted one item per line
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: lists
|
||||
- run: cargo xtask lists
|
||||
@@ -45,7 +45,14 @@ jobs:
|
||||
curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# The book's pictures are `solidcraft-cli snapshot`s, drawn on the GPU; the runner has none.
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
- name: Build the book
|
||||
env:
|
||||
SOLIDCRAFT_GPU: software
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solidcraft-web
|
||||
|
||||
@@ -27,11 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers).
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
@@ -79,6 +81,10 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.start(id)`. Colours come from `theme::Tokens`.
|
||||
- **One set of camera controls**: every page or canvas with a camera (viewport, Render
|
||||
preview, future flat pattern or drawing pages) calls `navigation::handle` with its `NavCaps`
|
||||
(orbit off for flat 2D pages). New canvases must use `navigation::handle`; never hand-roll
|
||||
camera input (a test rejects it).
|
||||
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
|
||||
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
|
||||
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
|
||||
@@ -117,6 +123,23 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
keep monitoring it while the task is active and make its pending status clear at handoff;
|
||||
do not claim it merged without checking.
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
@@ -147,5 +170,11 @@ before the draft is verified.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
+29
-1
@@ -16,7 +16,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
|---|---|---|---|
|
||||
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
|
||||
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
|
||||
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
|
||||
| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
|
||||
@@ -36,6 +36,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
|
||||
@@ -96,6 +97,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/tr.json | macrulz54 | Turkish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
@@ -109,6 +111,8 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.FCStd | SolidCraft contributors | original 10 × 20 × 30 mm test box saved by FreeCAD 1.1.4 run as an external program (`make_block_fcstd.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
| crates/io/src/native_cad/fixtures/block.3dm | SolidCraft contributors | original 10 × 20 × 30 mm test box written with McNeel's MIT-licensed rhino3dm Python package (`make_block_3dm.py` beside it); no third-party geometry | MIT OR Apache-2.0 |
|
||||
|
||||
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
|
||||
|
||||
@@ -139,6 +143,27 @@ work cooperative and adds checked run lengths, zero-run rejection, width validat
|
||||
header/input/radiance bounds and cancellation. Original licence texts are retained
|
||||
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
|
||||
## File-format crates
|
||||
|
||||
| Crate | Used for | Licence |
|
||||
|---|---|---|
|
||||
| gltf 1.4.1, gltf-json 1.4.1, gltf-derive 1.4.1 (https://github.com/gltf-rs/gltf) | glTF 2.0 import (crates/io/src/gltf_in.rs): the JSON document model and its reference checks; the GLB container, buffers and accessors are read by SolidCraft's own code | MIT OR Apache-2.0 |
|
||||
| inflections 1.1.1 (build-time, through gltf-derive) | case conversion in gltf's derive macros | MIT |
|
||||
| lazy_static 1.5.1 (through gltf-json) | | MIT OR Apache-2.0 |
|
||||
|
||||
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
|
||||
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
|
||||
|
||||
## FreeCAD and Rhino import library
|
||||
|
||||
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
|
||||
0.6.1 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`,
|
||||
`cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step`
|
||||
in native builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no
|
||||
default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS
|
||||
codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is
|
||||
copied into SolidCraft.
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolidCraft file | Original contribution | Licence |
|
||||
@@ -152,6 +177,7 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
|
||||
| vendor/truck-geometry | truck-geometry 0.5.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | intersection curves divided from their neighbours' surface parameters (vendor/README.md) |
|
||||
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
|
||||
|
||||
## Vendored renderer compatibility fix
|
||||
@@ -162,3 +188,5 @@ its original `egui.wgsl` shader are retained; no images or other artwork are cop
|
||||
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
|
||||
and shares that effective count with the surface attachments and CAD callbacks.
|
||||
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
|
||||
A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
|
||||
skipped with a warning instead of panicking (#1469).
|
||||
@@ -27,11 +27,13 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
|
||||
## Never crash
|
||||
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
|
||||
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The three exceptions, in the app crate
|
||||
(which denies it instead), have no safe API: two functions in
|
||||
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
|
||||
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers).
|
||||
method), the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
|
||||
records crashes inside native code such as graphics drivers) and the Windows-only
|
||||
`apps/solidcraft/src/native_frame.rs` (the window procedure that answers the resize-border hit
|
||||
test of the window without OS decorations).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
|
||||
returns an error ("not supported yet: …"); it never panics.
|
||||
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
|
||||
@@ -94,6 +96,23 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
## Claim = a draft PR
|
||||
- Before working on issue #N, check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- If none, branch from the latest `origin/main`, `git commit --allow-empty -m "claim: #N"`, push,
|
||||
and open a **draft** PR titled `<summary> (#N)` right away. **The lowest-numbered open PR for an
|
||||
issue wins**; if yours lost, close it. Don't claim by commenting or by editing files.
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim (you may start from its branch).
|
||||
- On a shared build machine, check the load before a big build (`cut -d' ' -f1 /proc/loadavg`)
|
||||
and wait while it is over 24; build with `nice`.
|
||||
|
||||
## Design file format
|
||||
A change that saves something new in a design adds ONE file, `crates/doc/src/format/pending/<name>.rs`
|
||||
(a `//!` note, and `pub fn step` if older designs must be rewritten). Never edit `FORMAT`,
|
||||
`JSON_STEPS`, the history in `format.rs`, another change's file or a `v<N>/` folder: the build
|
||||
works out the number (the next one while anything is pending), and the release folds pending
|
||||
changes into `v<N>/` with `scripts/fold-format-pending.sh`. See CONTRIBUTING.md.
|
||||
|
||||
## Application releases
|
||||
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
|
||||
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
|
||||
@@ -124,5 +143,11 @@ before the draft is verified.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
|
||||
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
|
||||
commands by area, from the registry) when commands are added or renamed.
|
||||
milestone task lands. `docs/features.md` (SolidCraft's commands by area, from the registry) and
|
||||
`THIRD-PARTY-LICENSES.txt` are regenerated on `main` after each merge by the Generated files
|
||||
workflow; never regenerate them in a PR.
|
||||
|
||||
## Lists many PRs add to
|
||||
Keep them conflict-free (CONTRIBUTING.md, "Lists many changes add to"): message catalogs sorted
|
||||
(`cargo xtask lists --fix`), `mod` lines in their sorted group, the language list English first
|
||||
then by id, generated files left to `main`, and a design-format change as one new file.
|
||||
+80
-7
@@ -98,8 +98,8 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
|
||||
open-source application to get reference values for a test is fine; reading its source is
|
||||
not.) Permissive
|
||||
dependencies are fine. Note which one you're adding and why in the PR, and run
|
||||
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
|
||||
dependencies are fine. Note which one you're adding and why in the PR. THIRD-PARTY-LICENSES.txt
|
||||
lists it once the change lands: the Generated files workflow rewrites it on `main`.
|
||||
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
|
||||
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
@@ -116,6 +116,12 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
|
||||
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
|
||||
face/edge/vertex counts against a value you can derive), and for UI work a scenario (below).
|
||||
6. **One set of camera controls.** Every page or canvas that shows a view (the modelling
|
||||
viewport, the Render preview, and any new page such as a flat pattern or drawing) calls
|
||||
`navigation::handle` (`crates/ui-egui/src/navigation.rs`) for its camera input, with
|
||||
`NavCaps` saying whether it can orbit (off for flat 2D pages) and pick. New canvases must use
|
||||
`navigation::handle`; never read drags, the wheel or pinch to move the camera yourself. The
|
||||
`hand_rolled_camera_input_is_rejected` test fails if a page does.
|
||||
|
||||
[AGENTS.md](AGENTS.md) has the long form of these rules. It's written for AI agents, but it's
|
||||
the most complete description of the house style.
|
||||
@@ -157,6 +163,13 @@ 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.
|
||||
|
||||
**PRs test what they touch; main and nightly test everything.** On a pull request, CI maps the
|
||||
changed files to workspace packages and runs Clippy, the test build and the tests for those and
|
||||
the packages that depend on them (`scripts/ci/affected.py`, self-tested in the `checks` job). A
|
||||
change to `Cargo.lock`, the root `Cargo.toml`, `.cargo/`, `.config/`, `.github/`, `vendor/`,
|
||||
`tests/`, `scripts/` or any file outside a package runs the whole workspace, as does every push
|
||||
to `main`, the nightly run and Run workflow.
|
||||
|
||||
**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
|
||||
@@ -167,8 +180,9 @@ Pull requests skip a few heavy tests (one to four minutes each), which run on ev
|
||||
`tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part),
|
||||
`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`
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
|
||||
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
|
||||
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
|
||||
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
|
||||
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
|
||||
|
||||
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
|
||||
@@ -186,18 +200,75 @@ the current results, and CI runs `cargo xtask oracle` on every pull request. A c
|
||||
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
|
||||
change an expected value to match SolidCraft's output.
|
||||
|
||||
## Changing the design file format
|
||||
|
||||
A design file says which format it is in (`"format": "solidcraft/N"`). If your change saves
|
||||
something new in a design (a field, a feature type, a new kind of name), or makes older designs
|
||||
mean something else, it needs a format change. **Add one file, and edit nothing shared:**
|
||||
|
||||
1. Create `crates/doc/src/format/pending/<what-it-is>.rs` (lowercase, dashes). Start it with a
|
||||
`//!` note: what is saved, what older designs get without it, and that the last release asks
|
||||
for an update instead of meeting it.
|
||||
2. If older designs must be rewritten as they open, put `pub fn step(v: &mut Value) ->
|
||||
crate::Result<()>` in the same file. Pending steps run in file-name order, after the older
|
||||
formats' steps.
|
||||
3. Don't touch `FORMAT`, `JSON_STEPS`, the history in `crates/doc/src/format.rs`, another
|
||||
change's file, or any `v<N>/` folder: those are fixed once released.
|
||||
|
||||
That's all. The build counts the files: while any are pending it writes the next format number,
|
||||
which no release reads, so a release refuses a design that has your change instead of losing it.
|
||||
A file also lists the pending changes it has (`format_pending`), so a later development build
|
||||
runs only the steps that file is missing. Two pull requests that each add a change merge without
|
||||
a conflict. `crates/doc/src/format/pending/README.md` has an example.
|
||||
|
||||
**At release time** the maintainer runs `scripts/fold-format-pending.sh`, which moves the pending
|
||||
files into `crates/doc/src/format/v<N>/` and fixes that number for good (see
|
||||
[docs/releasing.md](docs/releasing.md)). Never add a file to a `v<N>/` folder afterwards.
|
||||
|
||||
## Lists many changes add to
|
||||
|
||||
Some files gain an item in many pull requests at once. They are kept so that two changes that
|
||||
each add an item only meet when their items are neighbours, which spares everyone a conflict
|
||||
and a fresh CI run:
|
||||
|
||||
- **Message catalogs** (`assets/locales/*.json`): one message per line, sorted by id. Add your
|
||||
message anywhere and run `cargo xtask lists --fix`; `cargo xtask lists` (in `cargo xtask ci`
|
||||
and the Lists workflow) fails on an unsorted catalog or an id that appears twice.
|
||||
- **`mod` lines**: one per line, in the sorted group they belong to (`cargo fmt` keeps each group
|
||||
sorted). Add a new module into the existing group, not as a new group at the end.
|
||||
- **Languages** (`crates/ui-egui/src/i18n.rs`): one per line, English first, then by id (a test
|
||||
checks it).
|
||||
- **Generated files**: `THIRD-PARTY-LICENSES.txt` and `docs/features.md` are rewritten on `main`
|
||||
after each merge by the Generated files workflow (`cargo xtask licences`, `cargo xtask
|
||||
features`). Don't regenerate or edit them in a pull request.
|
||||
- **Design format**: one file per change, never an edit to a shared number (above).
|
||||
|
||||
## Sending a change
|
||||
|
||||
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
|
||||
comment on the issue so it gets the `in progress` label and two people don't build the same
|
||||
thing. For anything bigger than a bug fix that has no issue yet, open one first.
|
||||
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). For anything
|
||||
bigger than a bug fix that has no issue yet, open one first. **Claim the issue with a draft
|
||||
PR** before you start, so two people don't build the same thing:
|
||||
- Check nobody has it: `gh pr list -R bherbruck/solidcraft --state open --search "#N"`.
|
||||
- Branch from the latest `main`, commit `git commit --allow-empty -m "claim: #N"`, push, and
|
||||
open a **draft** PR titled `<summary> (#N)`, e.g. `Fillet: variable radius along a chain (#123)`.
|
||||
- **The lowest-numbered open PR for an issue wins.** If yours lost, close it (or offer to help
|
||||
on the other one).
|
||||
- Push work in progress at least daily. A draft with no pushes for 72 hours may be taken over:
|
||||
comment on it first, then open your own claim.
|
||||
- Mark the PR ready for review when it is done.
|
||||
2. Branch from `main` and keep the PR to one topic.
|
||||
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
|
||||
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
|
||||
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
|
||||
the expected ones).
|
||||
|
||||
Comment `/claim` on an issue to take it: you are assigned, it gets the `in progress` label, and
|
||||
the bot replies with the deadline. Open a draft PR referencing the issue within 72 hours, or the
|
||||
claim lapses and the issue is freed again; a claim with a PR is freed after 14 days without
|
||||
pushes. `/unclaim` releases it early (only you or a maintainer can). Issues a maintainer has
|
||||
taken are never freed automatically.
|
||||
|
||||
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
|
||||
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
|
||||
is required for changes or bugs that can be demonstrated in the app, including kernel,
|
||||
@@ -226,6 +297,8 @@ long as the change meets the rules above.
|
||||
|
||||
## Where help is wanted most
|
||||
|
||||
**Kernel speed (slow fillets, booleans, STEP):** please don't rewrite the current kernel code for speed; it is being replaced by [solidcore](https://github.com/bherbruck/solidcore) ([progress](https://bherbruck.github.io/solidcore/)). Report slow cases with a file under the `kernel-perf` label, and put kernel effort into solidcore instead. See [#1597](https://github.com/bherbruck/solidcraft/issues/1597).
|
||||
|
||||
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
|
||||
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
|
||||
In order of how much they'd help users:
|
||||
|
||||
Generated
+869
-53
File diff suppressed because it is too large.
Load diff
+14
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -36,6 +36,7 @@ syn = { version = "2", features = ["full"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
sha2 = "0.10"
|
||||
moxcms = { version = "0.8.1", default-features = false, features = ["extended_range"] }
|
||||
semver = "1"
|
||||
|
||||
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
|
||||
@@ -52,6 +53,17 @@ rayon = "1.12"
|
||||
# packages (3MF)
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
# glTF 2.0 import (MIT OR Apache-2.0): the document model and GLB container only; buffers are
|
||||
# read by crates/io itself, so no image decoding or file access comes with it.
|
||||
gltf = { version = "1.4", default-features = false, features = ["names"] }
|
||||
|
||||
# FreeCAD and Rhino import (Apache-2.0, pure Rust apart from zstd; native builds only).
|
||||
# Pinned: its IR and codec APIs change between minor versions. Open formats only: no default
|
||||
# features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx)
|
||||
# and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through.
|
||||
cadmpeg-core = "=0.6.1"
|
||||
cadmpeg-ir = "=0.6.1"
|
||||
cadmpeg-registry = { version = "=0.6.1", default-features = false, features = ["fcstd", "rhino", "step"] }
|
||||
|
||||
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
|
||||
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
|
||||
@@ -114,6 +126,7 @@ debug = "line-tables-only"
|
||||
|
||||
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
[patch.crates-io]
|
||||
truck-geometry = { path = "vendor/truck-geometry" }
|
||||
truck-shapeops = { path = "vendor/truck-shapeops" }
|
||||
truck-stepio = { path = "vendor/truck-stepio" }
|
||||
truck-meshalgo = { path = "vendor/truck-meshalgo" }
|
||||
|
||||
@@ -82,6 +82,9 @@ rebuild with no errors, replayed here at 2× speed.
|
||||
|
||||
## What works
|
||||
|
||||
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
|
||||
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
|
||||
projection, trimming, fillets and chamfers, including corners at the origin.
|
||||
@@ -129,13 +132,16 @@ rebuild with no errors, replayed here at 2× speed.
|
||||
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
|
||||
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
|
||||
assemblies with joints as URDF for robot simulators (docs/urdf.md);
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
3MF, STL, OBJ and glTF 2.0 (`.glb`/`.gltf`, its node tree as components and its materials as
|
||||
appearances) import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
|
||||
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
|
||||
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
|
||||
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)); FreeCAD
|
||||
`.FCStd` and Rhino `.3dm` solids import the same way through
|
||||
[cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI.
|
||||
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
|
||||
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
|
||||
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
|
||||
@@ -178,7 +184,13 @@ Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p s
|
||||
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.
|
||||
page in Preferences has an on/off switch and a speed control for the 3D mouse. The web app reads
|
||||
it too, through the browser; Firefox needs a 3DxWare setting first (see
|
||||
[Getting started › 3D mouse in the browser](docs/book/src/guide/getting-started.md#3d-mouse-in-the-browser)).
|
||||
|
||||
With 3DxWare on Windows, SolidCraft follows 3DxWare's profile for it when there is one, and
|
||||
3DxWare's object mode (tilt to roll, twist to spin) until there is. The mouse-wheel turns that
|
||||
3DxWare makes from the cap's motion are ignored, so the view doesn't zoom in steps as the cap tilts.
|
||||
|
||||
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
|
||||
@@ -228,9 +240,9 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
| Layer | Crates | Role |
|
||||
|---|---|---|
|
||||
| L0 | `geom` | vectors, planes, profiles, meshes and their measures |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer |
|
||||
| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and the headless scene description |
|
||||
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
|
||||
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features |
|
||||
| L4 | `engine` | the session and the command registry (everything is a command) |
|
||||
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
|
||||
+42
-4
@@ -7,7 +7,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
|
||||
| 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; FreeCAD/Rhino import; 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 | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) 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. |
|
||||
@@ -21,6 +21,8 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
|
||||
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
|
||||
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
Face-based Extrude Join (including outward Press Pull) now defaults to the picked body,
|
||||
so edge-touching assembly neighbours stay separate regardless of body order (#226).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
@@ -47,12 +49,15 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| 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), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
|
||||
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
|
||||
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Motion Trace (a picked point's path as a joint is driven or a study plays, closed loops following; kept as a 3D sketch spline), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), inverse kinematics (`joint.reach`: a part's point to a target through the joints on its chain, within limits and with loops closed, nearest pose and its stopper when out of reach; dragging a jointed part in the viewport moves it as far as its joints allow), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Gear, Rack and Pinion, Screw and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
|
||||
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
|
||||
| M11 | 2D drawings | deferred | — |
|
||||
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
|
||||
@@ -64,6 +69,18 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
|
||||
retain their saved two-finger scroll preference; camera motion is available through
|
||||
`view.navigate` and modifier-aware `ui.move` automation.
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
drag continues, then catch up to the exact final value. Changes to the selected geometry,
|
||||
operation or feature still discard obsolete results. Distance arrows and their value boxes
|
||||
follow the current frame's input.
|
||||
|
||||
## Physical material data
|
||||
|
||||
- Physical material card validation (#923/#922) provides typed SI mechanical and
|
||||
@@ -94,6 +111,12 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
gamut clipping only when explicitly requested. It is not a renderer/resource
|
||||
consumer; see [the supported policy and limits](docs/render-texture-normalization.md).
|
||||
|
||||
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
|
||||
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
|
||||
stay unchanged. HDRI, transmission and textures remain separate gates.
|
||||
@@ -207,6 +230,14 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
Crash Reports…) with a reviewed issue draft; agents read them through
|
||||
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
|
||||
anywhere. See [crash reports](docs/crash-reports.md).
|
||||
- Agent chat (off by default, desktop): a side panel that drives a coding agent the user
|
||||
already has (Claude Code through its ACP adapter, Codex, Gemini CLI, OpenCode, Grok Build, …) over the
|
||||
Agent Client Protocol, under the user's own login; SolidCraft stores no keys. The agent works
|
||||
through SolidCraft's MCP tools on a gated endpoint of its own: file actions wait for Allow or
|
||||
Deny, crash reports are off limits, 24 changes per message, one undo step per message, and
|
||||
the agent's whole process group ends with the panel or the app. Docks left or right,
|
||||
minimises to a strip. Grok Build discovery includes `~/.grok/bin` and reuses the user's
|
||||
Grok login. See [agent chat](docs/agent-chat.md).
|
||||
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
|
||||
writes a design's history as a script that `eval` and `run` replay into the same design —
|
||||
user parameters, units, sheet metal and plastic rules and materials as their own commands,
|
||||
@@ -279,11 +310,15 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
|
||||
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
|
||||
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
|
||||
curved chamfer chains with straight planar ends can terminate across two existing planar
|
||||
bevel faces, splitting their shared seam; picks on the same tangent chain are applied once (#1303);
|
||||
whole smooth loops of a planar face, picked whole or by one edge (tangent
|
||||
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
|
||||
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
|
||||
@@ -336,6 +371,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
|
||||
touch (no clean crossing) enter that profile as polylines.
|
||||
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
|
||||
A sweep with a twist, an end scale or a parallel or direction orientation (#771) follows its
|
||||
path that way too, even along sketch lines and arcs: B-spline walls through stations, ruled
|
||||
walls through denser stations where the path has corners or the profile has holes.
|
||||
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
|
||||
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
|
||||
certificate and refine within bounded node limits while retaining existing IDs.
|
||||
|
||||
+1897
-530
File diff suppressed because it is too large.
Load diff
@@ -21,5 +21,10 @@ path = "src/alias.rs"
|
||||
|
||||
[dependencies]
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Snapshot tests decode the pictures to compare them with a tolerance.
|
||||
image = { workspace = true }
|
||||
@@ -34,7 +34,6 @@ use serde_json::{Value, json};
|
||||
mod app_icon;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::render::render_png;
|
||||
use solidcraft_engine::view::Guides;
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
@@ -63,6 +62,10 @@ const USAGE: &str = "usage:
|
||||
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
|
||||
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
|
||||
solidcraft-cli licences
|
||||
solidcraft-cli gpu [--policy auto|hardware|software|cpu]
|
||||
(the GPU adapters a headless render can open, best first, the one the policy picks and the tier it
|
||||
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
|
||||
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
|
||||
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
|
||||
";
|
||||
|
||||
@@ -75,6 +78,7 @@ fn main() -> ExitCode {
|
||||
Some("snapshot") => cmd_snapshot(&args[1..]),
|
||||
Some("render") => render::run(&args[1..]),
|
||||
Some("exec") => cmd_exec(&args[1..]),
|
||||
Some("gpu") => cmd_gpu(&args[1..]),
|
||||
Some("commands") => {
|
||||
use std::io::Write as _;
|
||||
let mut s = Session::default();
|
||||
@@ -213,6 +217,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let json_out = json_out(args)?;
|
||||
let base = flag(args, "--in");
|
||||
let path = positional(args);
|
||||
@@ -334,13 +339,59 @@ fn cmd_script(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `gpu`: the headless adapter survey and the policy's pick.
|
||||
fn cmd_gpu(args: &[String]) -> Result<(), String> {
|
||||
use solidcraft_gpu::select::{Policy, survey};
|
||||
let policy = match args {
|
||||
[] => Policy::from_env()?,
|
||||
[flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?,
|
||||
_ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()),
|
||||
};
|
||||
let s = survey(policy);
|
||||
let chosen = s.chosen.and_then(|i| s.adapters.get(i)).cloned();
|
||||
// The tier pictures land on: a hardware GPU, a software adapter, or the CPU rasterizer.
|
||||
let renderer = s.lands_on.map(|l| l.renderer());
|
||||
println!(
|
||||
"{}",
|
||||
pretty(&json!({
|
||||
"policy": policy.name(),
|
||||
"backends": s.backends,
|
||||
"adapters": s.adapters,
|
||||
"chosen": chosen,
|
||||
"lands_on": s.lands_on,
|
||||
"renderer": renderer,
|
||||
}))
|
||||
);
|
||||
match s.lands_on {
|
||||
Some(solidcraft_gpu::select::Landing::Cpu) => {
|
||||
eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING);
|
||||
Ok(())
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
None => Err(format!("no adapter qualifies under the {} policy", policy.name())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw this process's pictures (snapshots, MCP screenshots, saved thumbnails) on the GPU with the
|
||||
/// viewport's pipelines: the headless device the `SOLIDCRAFT_GPU` policy picks, opened on the first
|
||||
/// picture; under `auto` with no qualifying adapter (or `cpu`), the CPU rasterizer, and the
|
||||
/// picture says so. Under `hardware` or `software` with no such adapter a picture fails with an
|
||||
/// error that names the policy.
|
||||
fn install_renderer() {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| {
|
||||
let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?;
|
||||
Ok(solidcraft_engine::capture::Picture { rgba, renderer })
|
||||
}));
|
||||
}
|
||||
|
||||
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
|
||||
if positional(args).is_none() && flag(args, "--in").is_none() {
|
||||
return Err(USAGE.to_string());
|
||||
}
|
||||
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
|
||||
let size = |name: &str, default: usize| match flag(args, name) {
|
||||
let size = |name: &str, default: u32| match flag(args, name) {
|
||||
None => Ok(default),
|
||||
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
|
||||
};
|
||||
@@ -364,9 +415,14 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
|
||||
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
|
||||
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
|
||||
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
|
||||
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
|
||||
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
|
||||
let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?;
|
||||
std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer});
|
||||
if let Some(w) = solidcraft_engine::capture::warning(png.renderer) {
|
||||
eprintln!("warning: {w}");
|
||||
reply["warning"] = json!(w);
|
||||
}
|
||||
println!("{}", pretty(&reply));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -412,6 +468,7 @@ fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let id = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
let p: Value = match args.get(1) {
|
||||
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
|
||||
@@ -427,6 +484,7 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
|
||||
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
|
||||
fn cmd_mcp(args: &[String]) -> Result<(), String> {
|
||||
install_renderer();
|
||||
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
|
||||
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
|
||||
if let Some(s) = &scope {
|
||||
|
||||
@@ -151,11 +151,20 @@ fn snapshot_runs_its_script_on_the_in_design() {
|
||||
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
|
||||
std::fs::read(&png).unwrap()
|
||||
};
|
||||
let alone = shot("alone.png", &[shifted.as_os_str()]);
|
||||
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
|
||||
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
|
||||
assert_ne!(alone, on_base, "the script ran on the --in design");
|
||||
assert_ne!(base_only, on_base);
|
||||
// Pictures are drawn on the GPU: without an adapter only the option checks below run.
|
||||
match solidcraft_gpu::capture::shared() {
|
||||
Ok(_) => {
|
||||
let alone = shot("alone.png", &[shifted.as_os_str()]);
|
||||
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
|
||||
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
|
||||
assert_ne!(alone, on_base, "the script ran on the --in design");
|
||||
assert_ne!(base_only, on_base);
|
||||
}
|
||||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("pictures left out: {e}")
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
|
||||
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(1));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
|
||||
//! design or one the script applies itself, and without the ground grid and the origin's axes
|
||||
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
|
||||
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). Pictures are drawn
|
||||
//! on the GPU and compared with a tolerance; without a GPU adapter the tests that draw are skipped
|
||||
//! with the reason printed (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
@@ -12,7 +14,52 @@ use solidcraft_mcp::{Headless, Server};
|
||||
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
const SIZE: (usize, usize) = (160, 100);
|
||||
/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP
|
||||
/// `screenshot` in this process draws as `solidcraft-cli mcp` does.
|
||||
fn gpu() -> bool {
|
||||
match solidcraft_gpu::capture::shared() {
|
||||
Ok(c) => {
|
||||
solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| {
|
||||
c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string())
|
||||
}));
|
||||
true
|
||||
}
|
||||
Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("skipping: {e}");
|
||||
false
|
||||
}
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG's size and RGBA pixels.
|
||||
fn decode(png: &[u8]) -> (u32, u32, Vec<u8>) {
|
||||
let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8();
|
||||
(img.width(), img.height(), img.into_raw())
|
||||
}
|
||||
|
||||
/// Pixels of two same-sized pictures that differ by more than a little in some channel.
|
||||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||||
let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b));
|
||||
assert_eq!((wa, ha), (wb, hb), "pictures of different sizes");
|
||||
a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count()
|
||||
}
|
||||
|
||||
/// The same picture: GPU output may differ by a few levels or a stray edge pixel between drivers.
|
||||
#[track_caller]
|
||||
fn assert_same(a: &[u8], b: &[u8], what: &str) {
|
||||
let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize);
|
||||
assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ");
|
||||
}
|
||||
|
||||
/// Different pictures: more than stray pixels differ.
|
||||
#[track_caller]
|
||||
fn assert_differs(a: &[u8], b: &[u8], what: &str) {
|
||||
let n = differing(a, b);
|
||||
assert!(n >= 20, "{what}: only {n} pixels differ");
|
||||
}
|
||||
|
||||
const SIZE: (u32, u32) = (160, 100);
|
||||
|
||||
fn camera() -> Value {
|
||||
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
|
||||
@@ -88,32 +135,38 @@ impl Drop for Scratch {
|
||||
/// not the home view the option used to be ignored for.
|
||||
#[test]
|
||||
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let home = dir.snapshot(&script, "home.png", &[]);
|
||||
let json = camera().to_string();
|
||||
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
assert_ne!(shot, home, "--camera was ignored");
|
||||
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
|
||||
assert_differs(&shot, &home, "--camera was ignored");
|
||||
assert_same(&shot, &dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})), "the MCP screenshot");
|
||||
// A perspective camera too, and a different eye gives a different picture.
|
||||
let mut near = camera();
|
||||
near["fov"] = json!(0.6);
|
||||
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
|
||||
assert_ne!(perspective, shot);
|
||||
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
|
||||
assert_differs(&perspective, &shot, "perspective");
|
||||
assert_same(&perspective, &dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})), "the MCP perspective screenshot");
|
||||
}
|
||||
|
||||
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
|
||||
/// standard view still looks as it did.
|
||||
#[test]
|
||||
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
|
||||
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
|
||||
assert_eq!(saved, by_camera);
|
||||
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
|
||||
assert_same(&saved, &by_camera, "the saved view");
|
||||
assert_same(&saved, &dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})), "the MCP saved view");
|
||||
// A standard view frames the model from its direction, as before: the home camera turned.
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
let mut s = Session::default();
|
||||
@@ -121,32 +174,38 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
|
||||
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
|
||||
assert_ne!(top, saved);
|
||||
assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top");
|
||||
assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view");
|
||||
assert_differs(&top, &saved, "top and the saved view");
|
||||
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
|
||||
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
|
||||
assert_ne!(iso, top);
|
||||
assert_same(&iso, &dir.snapshot(&script, "home.png", &[]), "iso is home");
|
||||
assert_differs(&iso, &top, "iso and top");
|
||||
}
|
||||
|
||||
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
|
||||
/// of its snapshot; `--view` and `--camera` still decide when given.
|
||||
#[test]
|
||||
fn a_script_can_carry_its_camera() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
|
||||
let plain = dir.script("plain.json", &[]);
|
||||
let carried = dir.snapshot(&script, "carried.png", &[]);
|
||||
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
|
||||
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
|
||||
assert_same(&carried, &dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]), "the carried camera");
|
||||
assert_differs(&carried, &dir.snapshot(&plain, "home.png", &[]), "the carried camera and home");
|
||||
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
|
||||
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
|
||||
assert_same(&top, &dir.snapshot(&plain, "plain-top.png", &["--view", "top"]), "--view decides");
|
||||
}
|
||||
|
||||
/// The grid and the axes each go when asked, and a render without both from any side shows the
|
||||
/// model alone: an empty design is then the same blank picture from every camera.
|
||||
#[test]
|
||||
fn the_grid_and_the_axes_can_be_left_out() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let json = camera().to_string();
|
||||
@@ -161,7 +220,7 @@ fn the_grid_and_the_axes_can_be_left_out() {
|
||||
let different = [&all, &no_grid, &no_axes, &bare];
|
||||
for (i, a) in different.iter().enumerate() {
|
||||
for b in &different[i + 1..] {
|
||||
assert_ne!(a, b, "{view:?}: two pictures are the same");
|
||||
assert_differs(a, b, &format!("{view:?}: two of the pictures"));
|
||||
}
|
||||
}
|
||||
// The same as the MCP's `grid` and `axes`.
|
||||
@@ -173,15 +232,151 @@ fn the_grid_and_the_axes_can_be_left_out() {
|
||||
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
dir.mcp(&script, out, a)
|
||||
};
|
||||
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
|
||||
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
|
||||
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
|
||||
assert_same(&no_grid, &mcp(json!({"grid": false}), "mcp-no-grid.png"), "the MCP's grid: false");
|
||||
assert_same(&no_axes, &mcp(json!({"axes": false}), "mcp-no-axes.png"), "the MCP's axes: false");
|
||||
assert_same(&bare, &mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"), "the MCP's bare picture");
|
||||
}
|
||||
let empty = dir.0.join("empty.json");
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
|
||||
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
|
||||
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
|
||||
assert_same(&blank("top", "blank-top.png"), &blank("front", "blank-front.png"), "blank from every side");
|
||||
assert_differs(&blank("top", "blank-top.png"), &dir.snapshot(&empty, "top.png", &["--view", "top"]), "blank and the grid");
|
||||
}
|
||||
|
||||
/// A snapshot of a box shows its silhouette where the camera puts it: the pixels that differ
|
||||
/// from an empty design's picture (same camera, no grid or axes) span the box's corners projected
|
||||
/// to pixels, within the edge and silhouette lines' width.
|
||||
#[test]
|
||||
fn a_snapshot_shows_the_part_where_the_camera_puts_it() {
|
||||
if !gpu() {
|
||||
return;
|
||||
}
|
||||
let dir = Scratch::new();
|
||||
let part = dir.0.join("box.json");
|
||||
let empty = dir.0.join("empty.json");
|
||||
let corner = [10.0, -20.0, -15.0];
|
||||
let size = [60.0, 40.0, 30.0];
|
||||
std::fs::write(
|
||||
&part,
|
||||
json!({"commands": [{"command": "solid.box", "params": {"length": size[0], "width": size[1], "height": size[2], "corner": corner}}]})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
|
||||
for cam in [
|
||||
camera(),
|
||||
json!({"target": [40, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 160}),
|
||||
json!({"target": [40, 0, 0], "yaw": -0.4, "pitch": 0.5, "distance": 150, "fov": 0.6}),
|
||||
] {
|
||||
let args = ["--camera", &cam.to_string(), "--no-grid", "--no-axes"];
|
||||
let (_, _, with) = decode(&dir.snapshot(&part, "part.png", &args));
|
||||
let (_, _, without) = decode(&dir.snapshot(&empty, "empty.png", &args));
|
||||
// The part's pixels: where the picture differs from the empty one.
|
||||
let (mut lo, mut hi) = ([u32::MAX; 2], [0u32; 2]);
|
||||
for (i, (p, q)) in with.as_chunks::<4>().0.iter().zip(without.as_chunks::<4>().0).enumerate() {
|
||||
if p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 24) {
|
||||
let (x, y) = (i as u32 % SIZE.0, i as u32 / SIZE.0);
|
||||
lo = [lo[0].min(x), lo[1].min(y)];
|
||||
hi = [hi[0].max(x), hi[1].max(y)];
|
||||
}
|
||||
}
|
||||
assert!(lo[0] <= hi[0], "{cam}: the part is not in the picture");
|
||||
// Where the camera puts the corners.
|
||||
let mut s = solidcraft_engine::Session::default();
|
||||
s.execute("view.apply", &json!({"camera": cam})).unwrap();
|
||||
let c = s.camera.unwrap();
|
||||
let vp = c.view_proj(f64::from(SIZE.0) / f64::from(SIZE.1), 1000.0);
|
||||
let (mut elo, mut ehi) = ([f64::MAX; 2], [f64::MIN; 2]);
|
||||
for k in 0..8 {
|
||||
let p = solidcraft_engine::geom::Vec3::new(
|
||||
corner[0] + size[0] * f64::from(k & 1),
|
||||
corner[1] + size[1] * f64::from((k >> 1) & 1),
|
||||
corner[2] + size[2] * f64::from((k >> 2) & 1),
|
||||
);
|
||||
let q = vp.apply(p);
|
||||
let (x, y) = ((q[0] / q[3] + 1.0) * 0.5 * f64::from(SIZE.0), (1.0 - q[1] / q[3]) * 0.5 * f64::from(SIZE.1));
|
||||
elo = [elo[0].min(x), elo[1].min(y)];
|
||||
ehi = [ehi[0].max(x), ehi[1].max(y)];
|
||||
}
|
||||
let near = |got: u32, want: f64| (f64::from(got) - want).abs() <= 2.5;
|
||||
assert!(
|
||||
near(lo[0], elo[0]) && near(lo[1], elo[1]) && near(hi[0] + 1, ehi[0]) && near(hi[1] + 1, ehi[1]),
|
||||
"{cam}: the part spans pixels {lo:?}..={hi:?}, its corners project to {elo:?}..{ehi:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `snapshot` under a `SOLIDCRAFT_GPU` policy: its reply and stderr.
|
||||
fn snapshot_under(dir: &Scratch, script: &Path, out: &str, policy: &str) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.env("SOLIDCRAFT_GPU", policy)
|
||||
.arg("snapshot")
|
||||
.arg(script)
|
||||
.arg("--out")
|
||||
.arg(dir.0.join(out))
|
||||
.args(["--width", &SIZE.0.to_string(), "--height", &SIZE.1.to_string(), "--view", "top"])
|
||||
.output()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The last tier: `SOLIDCRAFT_GPU=cpu` draws with the CPU rasterizer whatever adapters there are,
|
||||
/// says so (`renderer: cpu` and a warning that overlays are not drawn), and the picture is the
|
||||
/// rasterizer's own.
|
||||
#[test]
|
||||
fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let output = snapshot_under(&dir, &script, "cpu.png", "cpu");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(output.status.success(), "{stderr}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], "cpu", "{reply}");
|
||||
assert!(reply["warning"].as_str().is_some_and(|w| w.contains("not drawn")), "{reply}");
|
||||
assert!(stderr.contains("CPU rasterizer"), "{stderr}");
|
||||
let mut s = Session::default();
|
||||
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
|
||||
let mut cam = solidcraft_engine::view::home_camera(&s);
|
||||
cam.set_view(solidcraft_engine::render::StandardView::Top);
|
||||
let scene = solidcraft_engine::view::scene(&s, &cam);
|
||||
let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap();
|
||||
assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu);
|
||||
}
|
||||
|
||||
/// `hardware` (and `software`) never fall to the CPU rasterizer: without such an adapter the
|
||||
/// snapshot fails, naming the policy, and writes nothing; with one it says it was drawn on the GPU.
|
||||
/// `auto` lands where `solidcraft-cli gpu` says it does.
|
||||
#[test]
|
||||
fn a_strict_policy_never_falls_to_the_cpu() {
|
||||
let dir = Scratch::new();
|
||||
let script = dir.script("part.json", &[]);
|
||||
let survey = |policy: &str| {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["gpu", "--policy", policy]).output().unwrap();
|
||||
(o.status.success(), serde_json::from_slice::<Value>(&o.stdout).unwrap_or(Value::Null))
|
||||
};
|
||||
for policy in ["hardware", "software"] {
|
||||
let (found, report) = survey(policy);
|
||||
let output = snapshot_under(&dir, &script, &format!("{policy}.png"), policy);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if found {
|
||||
assert!(output.status.success(), "{policy}: {stderr}");
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], report["renderer"], "{policy}: {reply}");
|
||||
assert_ne!(reply["renderer"], "cpu");
|
||||
} else {
|
||||
assert_eq!(report["lands_on"], Value::Null, "{policy}: {report}");
|
||||
assert_eq!(output.status.code(), Some(1), "{policy}: {stderr}");
|
||||
assert!(stderr.contains(&format!("`{policy}` policy")) && stderr.contains("solidcraft-cli gpu"), "{policy}: {stderr}");
|
||||
assert!(!dir.0.join(format!("{policy}.png")).exists());
|
||||
}
|
||||
}
|
||||
let (found, report) = survey("auto");
|
||||
assert!(found, "auto always lands somewhere: {report}");
|
||||
let output = snapshot_under(&dir, &script, "auto.png", "auto");
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let reply: Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||
assert_eq!(reply["renderer"], report["renderer"], "{reply} vs {report}");
|
||||
let (_, cpu) = survey("cpu");
|
||||
assert_eq!((cpu["lands_on"].clone(), cpu["renderer"].clone()), (json!("cpu"), json!("cpu")), "{cpu}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response", "MediaQueryList", "Gamepad",
|
||||
] }
|
||||
@@ -23,6 +23,15 @@
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
|
||||
<meta name="theme-color" content="#343840">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/ai.storyteller.solidcraft.png">
|
||||
<link rel="apple-touch-icon" href="icons/256x256/ai.storyteller.solidcraft.png">
|
||||
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png" data-target-path="icons/32x32" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png" data-target-path="icons/256x256" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png" data-target-path="icons/512x512" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
@@ -32,6 +41,10 @@
|
||||
if ("serviceWorker" in navigator) {
|
||||
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
|
||||
}
|
||||
// Install as an app: the browser's offer can come before the app runs, so it is kept here for
|
||||
// the app's File ▸ Install app… item (src/install.rs). Installed, the browser makes no more offers.
|
||||
addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); window.solidcraftInstall = e; });
|
||||
addEventListener("appinstalled", () => { window.solidcraftInstall = null; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "SolidCraft",
|
||||
"short_name": "SolidCraft",
|
||||
"description": "Open-source parametric CAD built in Rust.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"background_color": "#e8ebf0",
|
||||
"theme_color": "#343840",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{ "src": "icons/32x32/ai.storyteller.solidcraft.png", "sizes": "32x32", "type": "image/png" },
|
||||
{ "src": "icons/256x256/ai.storyteller.solidcraft.png", "sizes": "256x256", "type": "image/png" },
|
||||
{ "src": "icons/512x512/ai.storyteller.solidcraft.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
@@ -458,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl,.obj,.glb,.gltf");
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
if let Some(body) = doc.body() {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Install as an app (`solidcraft_ui_egui::install`). The browser offers it with a
|
||||
//! `beforeinstallprompt` event, which can come before this code runs: index.html keeps the event
|
||||
//! as `window.solidcraftInstall` (and drops it once the app is installed). The offer is good for
|
||||
//! one prompt; a browser that is still willing makes a new one later. Safari makes no offer: it
|
||||
//! gets its steps, unless the app is already running installed.
|
||||
|
||||
use solidcraft_ui_egui::install::{InstallApp, Offer, safari_steps};
|
||||
use wasm_bindgen::{JsCast as _, JsValue};
|
||||
|
||||
/// Where index.html keeps the browser's offer.
|
||||
const OFFER: &str = "solidcraftInstall";
|
||||
|
||||
/// The browser's offer, kept by index.html (None: none, or it was used).
|
||||
fn offer() -> Option<js_sys::Object> {
|
||||
let window = web_sys::window()?;
|
||||
js_sys::Reflect::get(&window, &OFFER.into()).ok()?.dyn_into().ok()
|
||||
}
|
||||
|
||||
/// Show the browser's install prompt (from the File menu click, so the page may).
|
||||
fn prompt() {
|
||||
let Some(offer) = offer() else { return };
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = js_sys::Reflect::set(&window, &OFFER.into(), &JsValue::NULL);
|
||||
}
|
||||
let shown = js_sys::Reflect::get(&offer, &"prompt".into())
|
||||
.and_then(|f| f.dyn_into::<js_sys::Function>().map_err(JsValue::from))
|
||||
.and_then(|f| f.call0(&offer))
|
||||
.and_then(|p| p.dyn_into::<js_sys::Promise>().map_err(JsValue::from));
|
||||
match shown {
|
||||
// Accepted or dismissed, the browser handles it; a refusal is only logged.
|
||||
Ok(p) => wasm_bindgen_futures::spawn_local(async move {
|
||||
if let Err(e) = wasm_bindgen_futures::JsFuture::from(p).await {
|
||||
log::warn!("install prompt: {e:?}");
|
||||
}
|
||||
}),
|
||||
Err(e) => log::warn!("install prompt: {e:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page runs as the installed app (its own window, or from the Home Screen on iOS).
|
||||
fn running_installed(window: &web_sys::Window) -> bool {
|
||||
let standalone = window.match_media("(display-mode: standalone)").ok().flatten().is_some_and(|m| m.matches());
|
||||
let home_screen = js_sys::Reflect::get(&window.navigator(), &"standalone".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
standalone || home_screen
|
||||
}
|
||||
|
||||
/// The host service for File ▸ Install app…; the UI is drawn again whenever the browser
|
||||
/// makes or withdraws its offer (even with nothing else happening).
|
||||
pub fn service(ctx: &egui::Context) -> InstallApp {
|
||||
// Safari's steps, decided once: the browser and the way the page runs don't change.
|
||||
let steps = web_sys::window().filter(|w| !running_installed(w)).and_then(|w| {
|
||||
let nav = w.navigator();
|
||||
let vendor = js_sys::Reflect::get(&nav, &"vendor".into()).ok().and_then(|v| v.as_string()).unwrap_or_default();
|
||||
safari_steps(&nav.user_agent().unwrap_or_default(), &vendor, nav.max_touch_points())
|
||||
});
|
||||
if let Some(window) = web_sys::window() {
|
||||
for event in ["beforeinstallprompt", "appinstalled"] {
|
||||
let ctx = ctx.clone();
|
||||
let redraw = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback(event, redraw.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
redraw.forget();
|
||||
}
|
||||
}
|
||||
let offered = move || if offer().is_some() { Some(Offer::Prompt) } else { steps.map(Offer::Steps) };
|
||||
InstallApp { offer: Box::new(offered), prompt: Box::new(prompt) }
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
//!
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! sample part.
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2 (remembered until
|
||||
//! `?webgpu`), `?sample` opens the sample part.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod install;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
@@ -19,6 +23,7 @@ mod web {
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::files::{self, Files, Intent};
|
||||
use crate::space_mouse::SpaceMouse;
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
@@ -65,6 +70,7 @@ mod web {
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
space_mouse: SpaceMouse,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -83,7 +89,16 @@ mod web {
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
let mut incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
// A glTF file's buffers picked with it are kept next to it, not opened.
|
||||
incoming.retain_mut(|(name, bytes, _)| {
|
||||
let companion =
|
||||
std::path::Path::new(name.as_str()).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("bin"));
|
||||
if companion {
|
||||
vfs::insert(&format!("{}{name}", files::UPLOAD), std::mem::take(bytes));
|
||||
}
|
||||
!companion
|
||||
});
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
@@ -371,11 +386,14 @@ mod web {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.space_mouse.logic(&mut self.app, ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
self.space_mouse.raw_input_hook(ctx, self.app.preferences.space_mouse_enabled, raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -398,6 +416,29 @@ mod web {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
|
||||
/// has no query, so the choice has to outlive the page it was made on.
|
||||
const WEBGL_KEY: &str = "solidcraft.webgl";
|
||||
|
||||
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
|
||||
fn webgl() -> bool {
|
||||
let storage = web_sys::window().and_then(|w| w.local_storage().ok().flatten());
|
||||
let q = query();
|
||||
if q.contains("webgl") {
|
||||
if let Some(s) = &storage {
|
||||
let _ = s.set_item(WEBGL_KEY, "1");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if q.contains("webgpu") {
|
||||
if let Some(s) = &storage {
|
||||
let _ = s.remove_item(WEBGL_KEY);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
@@ -422,7 +463,7 @@ mod web {
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().contains("webgl")
|
||||
if webgl()
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -447,8 +488,11 @@ mod web {
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
|
||||
})),
|
||||
install_app: Some(crate::install::service(&cc.egui_ctx)),
|
||||
..Default::default()
|
||||
};
|
||||
// Thumbnails of saves and start-page cards.
|
||||
solidcraft_ui_egui::gpu::install_cpu_picture_renderer();
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
@@ -481,6 +525,7 @@ mod web {
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
space_mouse: SpaceMouse::default(),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The 3D mouse in the browser (#1336), through the Gamepad API.
|
||||
//!
|
||||
//! Chromium browsers list a 3Dconnexion device itself, with its six axes scaled to -1..1 from the
|
||||
//! device's ±350. Firefox lists only joysticks and gamepads (a 3D mouse is a HID multi-axis
|
||||
//! controller), but 3DxWare's virtual joystick carries the same six axes once 3DxWare's profile
|
||||
//! for Firefox sends them there. Both come in the device's own order and signs, so the shared
|
||||
//! navigation takes them with 3DxWare's object-mode mapping, as the Linux app does without a
|
||||
//! profile.
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE};
|
||||
|
||||
/// Logitech's vendor id, which older 3Dconnexion devices carry, and those devices' product ids
|
||||
/// (usb.ids). Logitech's own gamepads and wheels carry the same vendor id.
|
||||
const LOGITECH: u16 = 0x046d;
|
||||
const LOGITECH_3D_MICE: [u16; 11] = [0xc603, 0xc605, 0xc606, 0xc621, 0xc623, 0xc625, 0xc626, 0xc627, 0xc628, 0xc629, 0xc62b];
|
||||
/// 3Dconnexion's own vendor id. The browser lists only its 3D mice as gamepads: its mice, and the
|
||||
/// mouse side of its receivers, are not gamepads.
|
||||
const THREEDCONNEXION: u16 = 0x256f;
|
||||
/// 3DxWare's virtual joystick.
|
||||
const VIRTUAL_JOYSTICK: (u16, u16) = (0xbeef, 0x046d);
|
||||
|
||||
/// What a gamepad the browser lists is, when it is a 3D mouse. A device is read before the
|
||||
/// joystick: in a Chromium browser both are listed, and the joystick stays at rest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Source {
|
||||
Device,
|
||||
VirtualJoystick,
|
||||
}
|
||||
|
||||
/// The vendor and product ids in a gamepad's id: `Name (Vendor: 046d Product: c62b)` in Chromium
|
||||
/// browsers, `046d-c62b-Name` in Firefox.
|
||||
fn vendor_product(id: &str) -> Option<(u16, u16)> {
|
||||
fn hex(digits: &str) -> Option<u16> {
|
||||
if (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_hexdigit()) { u16::from_str_radix(digits, 16).ok() } else { None }
|
||||
}
|
||||
if let Some((_, ids)) = id.rsplit_once("Vendor: ") {
|
||||
let (vendor, rest) = ids.split_once(" Product: ")?;
|
||||
let product = rest.split(|c: char| !c.is_ascii_hexdigit()).next()?;
|
||||
return Some((hex(vendor)?, hex(product)?));
|
||||
}
|
||||
let mut parts = id.splitn(3, '-');
|
||||
let (vendor, product, _name) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
Some((hex(vendor)?, hex(product)?))
|
||||
}
|
||||
|
||||
/// Whether a gamepad is a 3D mouse, from the id the browser gives it.
|
||||
pub fn source(id: &str) -> Option<Source> {
|
||||
match vendor_product(id)? {
|
||||
VIRTUAL_JOYSTICK => Some(Source::VirtualJoystick),
|
||||
(THREEDCONNEXION, _) => Some(Source::Device),
|
||||
(LOGITECH, product) if LOGITECH_3D_MICE.contains(&product) => Some(Source::Device),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The six axes in the device's raw units, from a gamepad's axes (-1..1). Fewer than six: not a
|
||||
/// 3D mouse. A value that isn't a number reads as rest.
|
||||
pub fn axes(values: &[f64]) -> Option<Axes> {
|
||||
let raw = |i: usize| values.get(i).map(|&v| if v.is_finite() { (v.clamp(-1.0, 1.0) * f64::from(FULL_SCALE)).round() as i16 } else { 0 });
|
||||
Some(Axes { translation: [raw(0)?, raw(1)?, raw(2)?], rotation: [raw(3)?, raw(4)?, raw(5)?] })
|
||||
}
|
||||
|
||||
/// The 3D mouse to read among the ones listed: the first device, else the first joystick.
|
||||
pub fn choose(found: impl IntoIterator<Item = (Source, Axes)>) -> Option<Axes> {
|
||||
found.into_iter().min_by_key(|(source, _)| *source).map(|(_, axes)| axes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use browser::SpaceMouse;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod browser {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, DriverMapping, navigate_frame};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use solidcraft_ui_egui::space_mouse::WheelGuard;
|
||||
|
||||
use super::{axes, choose, source};
|
||||
|
||||
/// How often the page looks for the cap starting to move while nothing else redraws.
|
||||
const WAKE_MS: i32 = 50;
|
||||
|
||||
/// The 3D mouse the browser lists, if any.
|
||||
fn read() -> Option<Axes> {
|
||||
let pads = web_sys::window()?.navigator().get_gamepads().ok()?;
|
||||
choose(pads.iter().filter_map(|pad| {
|
||||
let pad = pad.dyn_into::<web_sys::Gamepad>().ok().filter(web_sys::Gamepad::connected)?;
|
||||
let source = source(&pad.id())?;
|
||||
let values: Vec<f64> = pad.axes().iter().map(|v| v.as_f64().unwrap_or(f64::NAN)).collect();
|
||||
Some((source, axes(&values)?))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads the 3D mouse once per frame and moves the view with it.
|
||||
#[derive(Default)]
|
||||
pub struct SpaceMouse {
|
||||
axes: Axes,
|
||||
/// The previous frame's time and the step since it (seconds).
|
||||
last: Option<f64>,
|
||||
seconds: f64,
|
||||
wheel: WheelGuard,
|
||||
/// Listening for gamepads (from the first frame with navigation on).
|
||||
listening: bool,
|
||||
/// The timer that wakes the app when the cap starts moving runs (from when a 3D mouse
|
||||
/// is first listed).
|
||||
waking: bool,
|
||||
/// Navigation is on (the waker reads it).
|
||||
enabled: Rc<Cell<bool>>,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
/// At the start of a frame: reads the cap and drops 3DxWare's emulated wheel from the
|
||||
/// frame's input, before the app sees it.
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, enabled: bool, raw: &mut egui::RawInput) {
|
||||
let now = raw.time.unwrap_or_else(|| js_sys::Date::now() / 1000.0);
|
||||
self.seconds = self.last.map_or(0.0, |last| now - last);
|
||||
self.last = Some(now);
|
||||
self.enabled.set(enabled);
|
||||
self.axes = Axes::default();
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
if !self.listening {
|
||||
self.listening = true;
|
||||
listen(ctx);
|
||||
}
|
||||
let reading = read();
|
||||
if reading.is_some() && !self.waking {
|
||||
self.waking = wake(ctx, &self.enabled);
|
||||
}
|
||||
// Inside the dead zone is rest: a cap resting just off zero doesn't keep redrawing.
|
||||
self.axes = reading.filter(|axes| axes.moving()).unwrap_or_default();
|
||||
self.wheel.filter(self.axes.moving(), now, &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn logic(&self, app: &mut SolveApp, ctx: &egui::Context) {
|
||||
navigate_frame(app, ctx, DriverMapping::object_mode(), self.axes, self.seconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 3D mouse is listed only once it has been moved on the page: redraw then, so the frame
|
||||
/// finds it without waiting for the pointer.
|
||||
fn listen(ctx: &egui::Context) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let ctx = ctx.clone();
|
||||
let on_connect = Closure::<dyn FnMut()>::new(move || ctx.request_repaint());
|
||||
let _ = window.add_event_listener_with_callback("gamepadconnected", on_connect.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_connect.forget();
|
||||
}
|
||||
|
||||
/// The browser sends no event when a gamepad moves, so a timer looks while the app is idle and
|
||||
/// redraws when the cap starts moving; the frames that follow keep going on their own.
|
||||
fn wake(ctx: &egui::Context, enabled: &Rc<Cell<bool>>) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let (ctx, enabled) = (ctx.clone(), Rc::clone(enabled));
|
||||
let tick = Closure::<dyn FnMut()>::new(move || {
|
||||
if enabled.get() && read().is_some_and(Axes::moving) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let started = window.set_interval_with_callback_and_timeout_and_arguments_0(tick.as_ref().unchecked_ref(), WAKE_MS).is_ok();
|
||||
// The timer lives as long as the page.
|
||||
tick.forget();
|
||||
started
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognises_3d_mice_in_each_browsers_ids() {
|
||||
// As Edge and Firefox list them (Windows 11, SpaceMouse Pro, 3DxWare 10).
|
||||
assert_eq!(source("SpaceMouse Pro (Vendor: 046d Product: c62b)"), Some(Source::Device));
|
||||
assert_eq!(source("Unknown Gamepad (Vendor: beef Product: 046d)"), Some(Source::VirtualJoystick));
|
||||
assert_eq!(source("beef-046d-Unknown Gamepad"), Some(Source::VirtualJoystick));
|
||||
// 3Dconnexion's own vendor id, any product; the older devices under Logitech's.
|
||||
assert_eq!(source("SpaceMouse Compact (Vendor: 256f Product: c635)"), Some(Source::Device));
|
||||
assert_eq!(source("256f-c652-3Dconnexion Universal Receiver"), Some(Source::Device));
|
||||
assert_eq!(source("46d-c626-SpaceNavigator"), Some(Source::Device));
|
||||
assert_eq!(source("SpaceNavigator (STANDARD GAMEPAD Vendor: 046d Product: c626)"), Some(Source::Device));
|
||||
for product in LOGITECH_3D_MICE {
|
||||
assert_eq!(source(&format!("x (Vendor: 046d Product: {product:04x})")), Some(Source::Device));
|
||||
assert_eq!(source(&format!("046d-{product:04X}-x")), Some(Source::Device));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepads_and_other_logitech_devices_are_not_3d_mice() {
|
||||
for id in [
|
||||
"Logitech Dual Action (STANDARD GAMEPAD Vendor: 046d Product: c216)",
|
||||
"046d-c21d-Logitech Gamepad F310",
|
||||
"G29 Driving Force Racing Wheel (Vendor: 046d Product: c24f)",
|
||||
"046d-c215-Logitech Extreme 3D",
|
||||
"NuLOOQ navigator (Vendor: 046d Product: c640)",
|
||||
"Xbox 360 Controller (XInput STANDARD GAMEPAD)",
|
||||
"45e-28e-Xbox 360 Wired Controller",
|
||||
"beef-046e-Unknown Gamepad",
|
||||
"beee-046d-Unknown Gamepad",
|
||||
"",
|
||||
"-",
|
||||
"--",
|
||||
"256f-",
|
||||
"256f-c635",
|
||||
"Vendor: 256f",
|
||||
"Vendor: 256f Product: ",
|
||||
"x (Vendor: 256fff Product: c635)",
|
||||
"x (Vendor: zz6f Product: c635)",
|
||||
"12345-c635-x",
|
||||
"256f--x",
|
||||
"SpaceMouse Pro",
|
||||
] {
|
||||
assert_eq!(source(id), None, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_never_panic() {
|
||||
// Hostile and odd ids: multi-byte text around the markers, markers repeated or cut short.
|
||||
for id in [
|
||||
"Vendor: é Product: ü",
|
||||
"Vendor: Vendor: 256f Product: c635)",
|
||||
"é-ü-ö",
|
||||
"Ⅰ (Vendor: 256f Product: c6Ⅰ)",
|
||||
"\u{0}-\u{0}-\u{0}",
|
||||
"Vendor: 256f Product: c635 Vendor: ",
|
||||
] {
|
||||
let _ = source(id);
|
||||
}
|
||||
let mut seed = 0x2545_f491_4f6c_dd1d_u64;
|
||||
let alphabet: Vec<char> = "0123456789abcdefABCDEF- ():VendorProductéⅠ\u{0}".chars().collect();
|
||||
for _ in 0..20_000 {
|
||||
let len = (seed % 40) as usize;
|
||||
let id: String = (0..len)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
alphabet[(seed % alphabet.len() as u64) as usize]
|
||||
})
|
||||
.collect();
|
||||
if let Some(Source::Device) = source(&id) {
|
||||
let (vendor, product) = vendor_product(&id).expect("recognised ids have both");
|
||||
assert!(vendor == THREEDCONNEXION || (vendor == LOGITECH && LOGITECH_3D_MICE.contains(&product)), "{id:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamepad_axes_are_the_devices_raw_units() {
|
||||
// Full deflection is the desktop's 350, and the browser's scale keeps the device's steps.
|
||||
let axes = axes(&[1.0, -1.0, 0.5, -0.5, 2.0 / 700.0, 0.0]).expect("six axes");
|
||||
assert_eq!(axes, Axes { translation: [350, -350, 175], rotation: [-175, 1, 0] });
|
||||
for raw in -350_i16..=350 {
|
||||
let value = f64::from(raw) / 350.0;
|
||||
assert_eq!(super::axes(&[value; 6]).map(|a| a.translation[0]), Some(raw));
|
||||
}
|
||||
// Past full deflection is full deflection; anything that isn't a number is rest.
|
||||
let hostile = super::axes(&[f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MIN_POSITIVE]).expect("six axes");
|
||||
assert_eq!(hostile, Axes { translation: [0, 0, 0], rotation: [350, -350, 0] });
|
||||
// A gamepad with fewer than six axes is not a 3D mouse; extra axes are ignored.
|
||||
assert_eq!(super::axes(&[1.0; 5]), None);
|
||||
assert_eq!(super::axes(&[]), None);
|
||||
assert_eq!(super::axes(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), Some(Axes::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_is_read_before_the_virtual_joystick() {
|
||||
let moving = Axes { translation: [100, 0, 0], rotation: [0; 3] };
|
||||
let other = Axes { translation: [0, 200, 0], rotation: [0; 3] };
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other), (Source::Device, moving)]), Some(moving));
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::VirtualJoystick, other)]), Some(moving));
|
||||
assert_eq!(choose([(Source::VirtualJoystick, other)]), Some(other));
|
||||
// Two devices: the first listed.
|
||||
assert_eq!(choose([(Source::Device, moving), (Source::Device, other)]), Some(moving));
|
||||
assert_eq!(choose([]), None);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ publish = false
|
||||
default-run = "solidcraft"
|
||||
|
||||
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
|
||||
# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither
|
||||
# has a safe API. Keep the rest in sync with [workspace.lints].
|
||||
# (macOS file-open events), src/native_crash.rs (Windows exception filter) and src/native_frame.rs
|
||||
# (Windows resize border) can allow it: none has a safe API. Keep the rest in sync with [workspace.lints].
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
@@ -36,13 +36,19 @@ getrandom = "0.3"
|
||||
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
|
||||
# API keys of the agent chat's API agents (docs/agent-chat.md) live in the system keychain:
|
||||
# Keychain on macOS, Credential Manager on Windows, the Secret Service (pure-Rust D-Bus) elsewhere.
|
||||
keyring = { version = "3.6", default-features = false, features = ["apple-native", "windows-native", "async-secret-service", "async-io", "crypto-rust"] }
|
||||
|
||||
[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"] }
|
||||
# The main window's handle for src/native_frame.rs (already in the tree through eframe and winit).
|
||||
raw-window-handle = "0.6"
|
||||
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
|
||||
# The window's resize border and DPI size (src/native_frame.rs): the window procedure, hit test, DPI.
|
||||
windows-sys = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
@@ -53,6 +59,8 @@ windows-sys = { version = "0.61", features = [
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Memory",
|
||||
"Win32_System_Threading",
|
||||
"Win32_UI_HiDpi",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -18,8 +18,9 @@ const MAX_CONNECTIONS: usize = 16;
|
||||
const MAX_BAD_LINES: usize = 8;
|
||||
|
||||
static OPEN: AtomicUsize = AtomicUsize::new(0);
|
||||
/// The token file this launch wrote, removed when the app ends.
|
||||
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed
|
||||
/// when the app ends.
|
||||
static TOKEN_FILES: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// A new random token: 32 bytes from the operating system, as hex.
|
||||
fn new_token() -> Option<String> {
|
||||
@@ -30,30 +31,55 @@ fn new_token() -> Option<String> {
|
||||
|
||||
/// Remove this launch's token file (at exit).
|
||||
pub fn remove_token_file() {
|
||||
if let Some(p) = TOKEN_FILE.get() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
if let Ok(files) = TOKEN_FILES.lock() {
|
||||
for p in files.iter() {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// Every connection must show the token before anything runs (docs/control-protocol.md).
|
||||
let Some(token) = control_auth::env_token().or_else(new_token) else {
|
||||
eprintln!("solidcraft: control server not started: no random token available");
|
||||
return rx;
|
||||
match control_auth::env_token().or_else(new_token) {
|
||||
Some(token) => {
|
||||
listen(port, token, tx, ctx, false);
|
||||
}
|
||||
None => eprintln!("solidcraft: control server not started: no random token available"),
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// The agent chat's own endpoint for its agent's MCP server (docs/agent-chat.md): a free loopback
|
||||
/// port with a fresh token of its own (never `SOLIDCRAFT_CONTROL_TOKEN`), written to the usual
|
||||
/// token file, where the MCP server finds it, so the token never passes through the agent. Its
|
||||
/// requests go to `tx`, where the chat's gate reads them; one may wait long for the user's Allow.
|
||||
pub fn listen_agent(tx: Sender<ControlRequest>, ctx: egui::Context) -> Option<u16> {
|
||||
let Some(token) = new_token() else {
|
||||
eprintln!("solidcraft: agent chat endpoint not started: no random token available");
|
||||
return None;
|
||||
};
|
||||
listen(0, token, tx, ctx, true)
|
||||
}
|
||||
|
||||
/// Listen on `port` (0: a free port the system picks) with `token`; the port it listens on.
|
||||
/// `patient`: every request may wait as long as a batch (a person decides on it).
|
||||
fn listen(port: u16, token: String, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) -> Option<u16> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
match control_auth::token_path(port) {
|
||||
Some(path) => match control_auth::write_token_file(&path, &token) {
|
||||
Ok(()) => {
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
let _ = TOKEN_FILE.set(path);
|
||||
if let Ok(mut files) = TOKEN_FILES.lock() {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!(
|
||||
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
|
||||
@@ -78,12 +104,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &token, tx, ctx);
|
||||
serve(stream, &token, tx, ctx, patient);
|
||||
OPEN.fetch_sub(1, Ordering::Relaxed);
|
||||
});
|
||||
}
|
||||
});
|
||||
rx
|
||||
Some(port)
|
||||
}
|
||||
|
||||
/// One line without its newline, or `None` at the end of the stream. A client that sends
|
||||
@@ -134,12 +160,35 @@ fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
|
||||
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
/// How long a request waits for the app's reply: most requests, and a batch (or every request,
|
||||
/// on a patient connection).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Waits {
|
||||
short: Duration,
|
||||
long: Duration,
|
||||
}
|
||||
|
||||
const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::from_secs(3600) };
|
||||
|
||||
/// A request that timed out but is still running in the app (#1618): its id and method, and where
|
||||
/// its reply will land.
|
||||
struct Pending {
|
||||
id: Value,
|
||||
method: String,
|
||||
reply: Receiver<Value>,
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS);
|
||||
}
|
||||
|
||||
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut bad = 0usize;
|
||||
let mut authenticated = false;
|
||||
let mut pending: Option<Pending> = None;
|
||||
loop {
|
||||
let line = match read_line(&mut reader, MAX_LINE) {
|
||||
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
|
||||
@@ -167,14 +216,46 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(wait).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
// A request that timed out may have finished since (#1618).
|
||||
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
|
||||
Ok(reply) => Some(reply),
|
||||
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
|
||||
Err(TryRecvError::Empty) => None,
|
||||
});
|
||||
let mut r = if method == "control.pending" {
|
||||
match (pending.take(), late) {
|
||||
(None, _) => json!({"ok": true, "result": {"pending": null}}),
|
||||
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
|
||||
(Some(p), None) => {
|
||||
let r = json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": false}});
|
||||
pending = Some(p);
|
||||
r
|
||||
}
|
||||
}
|
||||
} else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) {
|
||||
// Nothing more is queued behind it, to run unseen when it ends.
|
||||
json!({"ok": false, "busy": true, "error": format!("busy: request {} (`{}`) is still running; ask `control.pending` for its result", p.id, p.method)})
|
||||
} else {
|
||||
if late.is_some() {
|
||||
// Finished, and the client moved on without asking: it is dropped.
|
||||
pending = None;
|
||||
}
|
||||
// A batch replaces many short requests: the whole of it may take long (#743).
|
||||
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
|
||||
let (req, rrx) = ControlRequest::new(method.clone(), params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
match rrx.recv_timeout(wait) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
|
||||
pending = Some(Pending { id: id.clone(), method, reply: rrx });
|
||||
r
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
@@ -202,6 +283,62 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// #1618: a request that outlasts its wait says it is still running; the connection keeps its
|
||||
/// reply for `control.pending`, and refuses other requests as `busy` until it lands, so none
|
||||
/// is queued to run unseen behind it.
|
||||
#[test]
|
||||
fn a_timed_out_request_can_be_asked_for_later() {
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = ran.clone();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
seen.lock().unwrap().push(req.method.clone());
|
||||
if req.method == "slow" {
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
|
||||
std::thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().flatten().next() {
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
let mut out = s.try_clone().unwrap();
|
||||
let mut lines = BufReader::new(s);
|
||||
let mut ask = |v: Value| -> Value {
|
||||
writeln!(out, "{v}").unwrap();
|
||||
let mut l = String::new();
|
||||
lines.read_line(&mut l).unwrap();
|
||||
serde_json::from_str(&l).unwrap()
|
||||
};
|
||||
let r = ask(json!({"id": 1, "method": "slow", "token": token}));
|
||||
assert!(r["ok"] == false && r["running"] == true && r["error"].as_str().is_some_and(|e| e.starts_with("timeout")), "{r}");
|
||||
let r = ask(json!({"id": 2, "method": "ui.view"}));
|
||||
assert!(r["busy"] == true && r["id"] == 2, "{r}");
|
||||
let r = ask(json!({"id": 3, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(1), &json!(false)), "{r}");
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
let r = ask(json!({"id": 4, "method": "control.pending"}));
|
||||
assert_eq!(
|
||||
(&r["result"]["pending"], &r["result"]["done"], &r["result"]["reply"]["result"]),
|
||||
(&json!(1), &json!(true), &json!("slow")),
|
||||
"{r}"
|
||||
);
|
||||
let r = ask(json!({"id": 5, "method": "control.pending"}));
|
||||
assert_eq!(r["result"]["pending"], Value::Null, "{r}");
|
||||
let r = ask(json!({"id": 6, "method": "ui.view"}));
|
||||
assert_eq!((&r["ok"], &r["result"]), (&json!(true), &json!("ui.view")), "{r}");
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
|
||||
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
|
||||
/// need not be repeated on that connection.
|
||||
@@ -223,7 +360,7 @@ mod tests {
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default(), false));
|
||||
}
|
||||
});
|
||||
let session = |lines: &[String]| {
|
||||
|
||||
+104
-3
@@ -6,7 +6,7 @@
|
||||
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
|
||||
//!
|
||||
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
|
||||
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
|
||||
//! `.3mf`/`.stl`/`.obj`/`.glb`/`.gltf` file as a new design holding mesh bodies.
|
||||
//!
|
||||
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
||||
@@ -21,6 +21,8 @@ mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_crash;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod native_frame;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod open_documents;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
@@ -75,6 +77,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Before the app's own hook, which adds the control channel's events.
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw);
|
||||
self.solve.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -84,6 +89,8 @@ impl eframe::App for App {
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
// The agent chat's agent and everything it started end with the app.
|
||||
solidcraft_ui_egui::agent_chat::shutdown(&mut self.solve);
|
||||
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
solidcraft_engine::crash::end_session();
|
||||
// An update asked to restart into the new version or to run its installer.
|
||||
@@ -104,7 +111,7 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
let future = open_parent
|
||||
.clone()
|
||||
.add_filter(
|
||||
"Designs, STEP, IGES and meshes",
|
||||
"Designs, CAD files and meshes",
|
||||
&[
|
||||
io::DESIGN_EXT,
|
||||
io::LEGACY_DESIGN_EXT,
|
||||
@@ -117,16 +124,27 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
"iges",
|
||||
"IGS",
|
||||
"IGES",
|
||||
"FCStd",
|
||||
"fcstd",
|
||||
"3dm",
|
||||
"3DM",
|
||||
"3mf",
|
||||
"3MF",
|
||||
"stl",
|
||||
"STL",
|
||||
"obj",
|
||||
"OBJ",
|
||||
"glb",
|
||||
"GLB",
|
||||
"gltf",
|
||||
"GLTF",
|
||||
],
|
||||
)
|
||||
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
|
||||
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
|
||||
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
|
||||
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
|
||||
.add_filter("FreeCAD and Rhino", &["FCStd", "fcstd", "3dm", "3DM"])
|
||||
.add_filter("Mesh (3MF, STL, OBJ, glTF)", &["3mf", "3MF", "stl", "STL", "obj", "OBJ", "glb", "GLB", "gltf", "GLTF"])
|
||||
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
|
||||
.add_filter("All files", &["*"])
|
||||
.pick_file();
|
||||
@@ -155,6 +173,10 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
http_get: Some(std::sync::Arc::new(http_get)),
|
||||
http_download: Some(std::sync::Arc::new(http_download)),
|
||||
// Already an installed app: only the browser build offers to install.
|
||||
install_app: None,
|
||||
agent_http: Some(std::sync::Arc::new(agent_http)),
|
||||
secrets: Some(std::sync::Arc::new(Keychain)),
|
||||
show_in_folder: Some(Box::new(|path| {
|
||||
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
|
||||
@@ -211,6 +233,71 @@ fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// HTTP for the agent chat's API agents (OpenAI-compatible endpoints, docs/agent-chat.md): to the
|
||||
/// URL the user configured, which may be plain HTTP on this computer (Ollama, LM Studio). No
|
||||
/// redirects (a key must not follow one elsewhere), at most 16 MB back.
|
||||
fn agent_http(r: &solidcraft_ui_egui::agent_chat::backend::HttpRequest) -> Result<(u16, Vec<u8>), String> {
|
||||
use std::time::Duration;
|
||||
let tls = ureq::tls::TlsConfig::builder()
|
||||
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
|
||||
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
|
||||
.build();
|
||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
||||
.tls_config(tls)
|
||||
.http_status_as_error(false)
|
||||
.timeout_global(Some(Duration::from_secs(r.timeout_s.clamp(1, 900))))
|
||||
.timeout_connect(Some(Duration::from_secs(10)))
|
||||
.user_agent("SolidCraft")
|
||||
.max_redirects(0)
|
||||
.build()
|
||||
.into();
|
||||
let mut response = if r.method == "POST" {
|
||||
let mut req = agent.post(&r.url);
|
||||
for (k, v) in &r.headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
req.send(r.body.as_deref().unwrap_or_default())
|
||||
} else {
|
||||
let mut req = agent.get(&r.url);
|
||||
for (k, v) in &r.headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
req.call()
|
||||
}
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| e.to_string())?;
|
||||
Ok((status, body))
|
||||
}
|
||||
|
||||
/// API keys in the system keychain (service "SolidCraft agent chat", one entry per agent).
|
||||
struct Keychain;
|
||||
|
||||
impl Keychain {
|
||||
fn entry(id: &str) -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new("SolidCraft agent chat", id).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl solidcraft_ui_egui::agent_chat::backend::Secrets for Keychain {
|
||||
fn get(&self, id: &str) -> Result<Option<String>, String> {
|
||||
match Self::entry(id)?.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
fn set(&self, id: &str, secret: &str) -> Result<(), String> {
|
||||
Self::entry(id)?.set_password(secret).map_err(|e| e.to_string())
|
||||
}
|
||||
fn delete(&self, id: &str) -> Result<(), String> {
|
||||
match Self::entry(id)?.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
|
||||
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
|
||||
use solidcraft_ui_egui::update::FetchError;
|
||||
@@ -465,6 +552,15 @@ fn main() -> eframe::Result {
|
||||
let mut app = SolveApp::new(Session::default(), services(cc));
|
||||
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
// Windows: the OS hit test offers the resize border, at the window's DPI, from the
|
||||
// first message (#1381).
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
app.native_resize_border = CUSTOM_TITLEBAR && native_frame::install(cc);
|
||||
if CUSTOM_TITLEBAR && !app.native_resize_border {
|
||||
eprintln!("SolidCraft window: no native resize border; egui edge zones instead");
|
||||
}
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
app.cpu_meshing = safe_mode;
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
|
||||
@@ -474,6 +570,7 @@ fn main() -> eframe::Result {
|
||||
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
|
||||
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
solidcraft_ui_egui::gpu::install_picture_renderer(rs);
|
||||
// The graphics driver is loaded now; it may have put its own exception filter
|
||||
// in front of ours.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -490,6 +587,10 @@ fn main() -> eframe::Result {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
// The agent chat's MCP bridge reaches the app through an endpoint of its own, started
|
||||
// on a free port the first time the chat connects (docs/agent-chat.md).
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
app.agent_chat.control_starter = Some(Box::new(move |tx| control_server::listen_agent(tx, ctx.clone())));
|
||||
if sample {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Windows: the resize border of the window without OS decorations (#1381).
|
||||
//!
|
||||
//! The window keeps its native thick frame (`WS_THICKFRAME`, so Aero Snap, Win+arrows and the
|
||||
//! OS's own move and size loops work) but winit draws no frame: its `WM_NCCALCSIZE` makes the
|
||||
//! whole window client area, and every point then hit-tests as `HTCLIENT`. This module puts the
|
||||
//! resize border back by answering `WM_NCHITTEST` itself: within Windows' resize-border width
|
||||
//! for the window's current DPI from each edge (`solidcraft_ui_egui::titlebar::resize_hit`;
|
||||
//! 8 px at 100 %, corners win), it returns `HTLEFT`, `HTTOPRIGHT`, … So from the first message
|
||||
//! the OS shows its resize cursors and runs its own resize, whatever egui has laid out, whatever
|
||||
//! the scale; nothing waits for a first frame or a first size change.
|
||||
//!
|
||||
//! It also answers `WM_GETDPISCALEDSIZE` with the size the window will really have at the new
|
||||
//! DPI (the same logical size, as winit computes it), so the rect Windows suggests while the
|
||||
//! title bar is dragged onto a monitor with another scale is the rect winit then applies: the
|
||||
//! window rescales once at the crossing and stays under the pointer, instead of the OS's move
|
||||
//! loop and winit disagreeing about a frame the window doesn't have.
|
||||
//!
|
||||
//! Replacing a window procedure has no safe API, hence the `unsafe_code` allowance on this
|
||||
//! module (the app crate denies it; see `native_crash.rs` and `open_documents.rs` for the others).
|
||||
//! The procedure is installed once, on the thread that owns the window, for the main window only,
|
||||
//! and calls the previous one (winit's) for everything it doesn't answer.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use solidcraft_ui_egui::titlebar;
|
||||
use windows_sys::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, SIZE, WPARAM};
|
||||
use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi};
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GWL_STYLE, GWLP_WNDPROC, GetClientRect, GetWindowLongPtrW, GetWindowRect, HTBOTTOM, HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT,
|
||||
HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, IsIconic, IsZoomed, SM_CXPADDEDBORDER, SM_CXSIZEFRAME, SetWindowLongPtrW, WM_GETDPISCALEDSIZE,
|
||||
WM_NCDESTROY, WM_NCHITTEST, WNDPROC, WS_THICKFRAME,
|
||||
};
|
||||
|
||||
/// The window procedure ours replaced (winit's), as an address; 0 before [`install`].
|
||||
static PREVIOUS: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
/// Answer the main window's hit test and DPI-size questions from now on. Call it once, on the
|
||||
/// thread that created the window (eframe's app creator). `false` if it isn't a Win32 window.
|
||||
pub fn install(window: &impl HasWindowHandle) -> bool {
|
||||
let Ok(handle) = window.window_handle() else { return false };
|
||||
let RawWindowHandle::Win32(w) = handle.as_raw() else { return false };
|
||||
let hwnd = w.hwnd.get() as HWND;
|
||||
if PREVIOUS.load(Ordering::SeqCst) != 0 {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: `hwnd` is the live window eframe just created on this thread; `frame_proc` has the
|
||||
// WNDPROC signature and stays valid for the life of the process. SetWindowLongPtrW returns
|
||||
// the previous procedure, which `frame_proc` calls for every other message.
|
||||
let previous = unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, frame_proc as *const () as isize as _) };
|
||||
// `as _`: on 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
|
||||
// returns i32.
|
||||
if previous == 0 {
|
||||
return false;
|
||||
}
|
||||
PREVIOUS.store(previous as _, Ordering::SeqCst);
|
||||
true
|
||||
}
|
||||
|
||||
/// The procedure ours replaced.
|
||||
fn previous() -> WNDPROC {
|
||||
let p = PREVIOUS.load(Ordering::SeqCst);
|
||||
// SAFETY: a nonzero value is the WNDPROC that SetWindowLongPtrW returned in `install`.
|
||||
(p != 0).then(|| unsafe { std::mem::transmute::<isize, unsafe extern "system" fn(HWND, u32, WPARAM, LPARAM) -> LRESULT>(p) })
|
||||
}
|
||||
|
||||
unsafe extern "system" fn frame_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
|
||||
let prev = previous();
|
||||
// SAFETY: the arguments are the message as Windows delivered it to this window.
|
||||
let call_previous = || unsafe { CallWindowProcW(prev, hwnd, msg, wparam, lparam) };
|
||||
match msg {
|
||||
WM_NCHITTEST => {
|
||||
let default = call_previous();
|
||||
// Screen coordinates, signed (monitors left of or above the primary one).
|
||||
let (x, y) = (i32::from(lparam as u16 as i16), i32::from((lparam >> 16) as u16 as i16));
|
||||
// The border, else what winit and the system say (`HTCLIENT` everywhere else).
|
||||
border_hit(hwnd, x, y).map_or(default, |hit| hit as LRESULT)
|
||||
}
|
||||
WM_GETDPISCALEDSIZE => {
|
||||
let new_dpi = wparam as u32;
|
||||
// SAFETY: for this message lParam points to a SIZE the system reads back.
|
||||
match (resizable(hwnd), client_size(hwnd), unsafe { (lparam as *mut SIZE).as_mut() }) {
|
||||
(true, Some(size), Some(out)) => {
|
||||
// SAFETY: a window handle the system passed in.
|
||||
let old_dpi = unsafe { GetDpiForWindow(hwnd) };
|
||||
match titlebar::dpi_scaled_size(size, old_dpi, new_dpi) {
|
||||
Some((cx, cy)) => {
|
||||
*out = SIZE { cx, cy };
|
||||
1
|
||||
}
|
||||
None => call_previous(),
|
||||
}
|
||||
}
|
||||
_ => call_previous(),
|
||||
}
|
||||
}
|
||||
WM_NCDESTROY => {
|
||||
// Hand the window back to winit's procedure for its last message.
|
||||
if prev.is_some() {
|
||||
// SAFETY: restores the procedure this window had before `install`.
|
||||
unsafe { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, PREVIOUS.load(Ordering::SeqCst) as _) };
|
||||
}
|
||||
call_previous()
|
||||
}
|
||||
_ => call_previous(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The window can be resized by its border now: it has the thick frame (not fullscreen, which
|
||||
/// drops it) and is neither maximized nor minimized.
|
||||
fn resizable(hwnd: HWND) -> bool {
|
||||
// SAFETY: plain queries on a window handle the system passed in.
|
||||
unsafe {
|
||||
let style = GetWindowLongPtrW(hwnd, GWL_STYLE) as u32;
|
||||
style & WS_THICKFRAME != 0 && IsZoomed(hwnd) == 0 && IsIconic(hwnd) == 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The client area's size in pixels (the whole window: winit gives it no frame).
|
||||
fn client_size(hwnd: HWND) -> Option<(i32, i32)> {
|
||||
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
// SAFETY: `r` is a valid RECT to fill.
|
||||
(unsafe { GetClientRect(hwnd, &mut r) } != 0).then_some((r.right - r.left, r.bottom - r.top))
|
||||
}
|
||||
|
||||
/// Windows' resize border at the window's DPI: the sizing frame plus its padding, as Windows
|
||||
/// itself sizes it, or 8 px scaled when the system has no answer.
|
||||
fn border_px(hwnd: HWND) -> f32 {
|
||||
// SAFETY: plain queries.
|
||||
let (dpi, metric) = unsafe {
|
||||
let dpi = GetDpiForWindow(hwnd);
|
||||
(dpi, GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi) + GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi))
|
||||
};
|
||||
let scale = if dpi > 0 { dpi as f32 / 96.0 } else { 1.0 };
|
||||
if metric > 0 { metric as f32 } else { titlebar::resize_border_px(scale) }
|
||||
}
|
||||
|
||||
/// The `HT*` resize code at screen point (`x`, `y`), if it is on the border.
|
||||
fn border_hit(hwnd: HWND, x: i32, y: i32) -> Option<u32> {
|
||||
if !resizable(hwnd) {
|
||||
return None;
|
||||
}
|
||||
let mut r = RECT { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
// SAFETY: `r` is a valid RECT to fill.
|
||||
if unsafe { GetWindowRect(hwnd, &mut r) } == 0 {
|
||||
return None;
|
||||
}
|
||||
let w = egui::Rect::from_min_max(egui::pos2(r.left as f32, r.top as f32), egui::pos2(r.right as f32, r.bottom as f32));
|
||||
titlebar::resize_hit(w, egui::pos2(x as f32, y as f32), border_px(hwnd)).map(hit_code)
|
||||
}
|
||||
|
||||
fn hit_code(d: egui::ResizeDirection) -> u32 {
|
||||
use egui::ResizeDirection::*;
|
||||
match d {
|
||||
North => HTTOP,
|
||||
South => HTBOTTOM,
|
||||
West => HTLEFT,
|
||||
East => HTRIGHT,
|
||||
NorthWest => HTTOPLEFT,
|
||||
NorthEast => HTTOPRIGHT,
|
||||
SouthWest => HTBOTTOMLEFT,
|
||||
SouthEast => HTBOTTOMRIGHT,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_direction_has_its_own_hit_code() {
|
||||
use egui::ResizeDirection::*;
|
||||
let codes: Vec<u32> = [North, South, West, East, NorthWest, NorthEast, SouthWest, SouthEast].into_iter().map(hit_code).collect();
|
||||
assert_eq!(codes, vec![HTTOP, HTBOTTOM, HTLEFT, HTRIGHT, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT]);
|
||||
}
|
||||
}
|
||||
+121
-223
@@ -15,79 +15,18 @@ use hid_descriptor::Layout;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::events::Event;
|
||||
use solidcraft_engine::render::Camera;
|
||||
use solidcraft_ui_egui::SolveApp;
|
||||
use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, DriverMapping, WheelGuard, navigate_frame};
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct AxisRule {
|
||||
output: usize,
|
||||
enabled: bool,
|
||||
reversed: bool,
|
||||
scale: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct DriverMapping {
|
||||
axes: [AxisRule; 6],
|
||||
overall_scale: f64,
|
||||
}
|
||||
|
||||
impl Default for DriverMapping {
|
||||
fn default() -> Self {
|
||||
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
|
||||
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
|
||||
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
|
||||
/// profile, and the raw axes are used until it does.
|
||||
fn without_profile() -> Self {
|
||||
let mut mapping = Self::default();
|
||||
if cfg!(target_os = "linux") {
|
||||
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
|
||||
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
|
||||
}
|
||||
mapping
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Motion {
|
||||
translation: [f64; 3],
|
||||
rotation: [f64; 3],
|
||||
}
|
||||
|
||||
impl DriverMapping {
|
||||
fn apply(self, axes: Axes) -> Motion {
|
||||
let raw = [
|
||||
normalized(axes.translation[0]),
|
||||
normalized(axes.translation[1]),
|
||||
normalized(axes.translation[2]),
|
||||
normalized(axes.rotation[0]),
|
||||
normalized(axes.rotation[1]),
|
||||
normalized(axes.rotation[2]),
|
||||
];
|
||||
let mut logical = [0.0; 6];
|
||||
for (input, rule) in self.axes.iter().enumerate() {
|
||||
if rule.enabled {
|
||||
let sign = if rule.reversed { -1.0 } else { 1.0 };
|
||||
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
|
||||
}
|
||||
}
|
||||
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
|
||||
}
|
||||
/// The mapping used when no 3DxWare profile is found: what the driver applies by default in
|
||||
/// object mode. On Linux there is no 3DxWare (#1172); on Windows 3DxWare has no profile for
|
||||
/// SolidCraft until one is made, and the raw axes swap roll and spin (#1380).
|
||||
fn without_profile() -> DriverMapping {
|
||||
DriverMapping::object_mode()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
@@ -381,6 +320,9 @@ pub struct SpaceMouse {
|
||||
last_frame: Instant,
|
||||
driver_mapping: DriverMapping,
|
||||
mapping_checked_at: Instant,
|
||||
/// Drops 3DxWare's emulated wheel while the cap moves (#1380).
|
||||
wheel: WheelGuard,
|
||||
started: Instant,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
@@ -395,84 +337,53 @@ impl SpaceMouse {
|
||||
Self {
|
||||
shared,
|
||||
last_frame: now,
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
|
||||
driver_mapping: load_driver_mapping().unwrap_or_else(without_profile),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Before the app's own hook (which adds the control channel's events): with navigation on,
|
||||
/// the wheel events 3DxWare makes from the cap's motion are dropped.
|
||||
pub fn raw_input_hook(&mut self, enabled: bool, raw: &mut egui::RawInput) {
|
||||
if !enabled {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let moving = self.shared.lock().map(|reading| reading.current(now).moving()).unwrap_or(false);
|
||||
self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events);
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
|
||||
let now = Instant::now();
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
let seconds = now.duration_since(self.last_frame).as_secs_f64();
|
||||
self.last_frame = now;
|
||||
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
|
||||
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
|
||||
let updated = load_driver_mapping().unwrap_or_else(without_profile);
|
||||
if updated != self.driver_mapping {
|
||||
log::info!("3D mouse: applying updated 3DxWare axis profile");
|
||||
self.driver_mapping = updated;
|
||||
}
|
||||
self.mapping_checked_at = now;
|
||||
}
|
||||
if !app.preferences.space_mouse_enabled || app.home.open {
|
||||
return;
|
||||
}
|
||||
let axes = match self.shared.lock() {
|
||||
Ok(reading) => reading.current(now),
|
||||
Err(_) => return,
|
||||
};
|
||||
if axes == Axes::default() {
|
||||
return;
|
||||
}
|
||||
let speed = f64::from(app.preferences.space_mouse_speed);
|
||||
if !speed.is_finite() {
|
||||
return;
|
||||
}
|
||||
app.begin_external_navigation();
|
||||
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
|
||||
navigate(
|
||||
&mut app.cam,
|
||||
self.driver_mapping.apply(axes),
|
||||
seconds * speed.clamp(0.1, 5.0),
|
||||
height,
|
||||
app.session.active_sketch.is_some(),
|
||||
app.preferences.zoom_reverse,
|
||||
);
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
|
||||
/// per second to pan and about two radians per second to turn, independent of frame rate.
|
||||
fn normalized(value: i16) -> f64 {
|
||||
let v = f64::from(value);
|
||||
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
|
||||
}
|
||||
|
||||
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = motion.translation;
|
||||
let [rx, ry, rz] = motion.rotation;
|
||||
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
|
||||
// profile above can swap the physical forward/back and up/down axes before they reach here.
|
||||
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
|
||||
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
|
||||
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
|
||||
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
|
||||
// navigation mode, so swapping them again here would make Roll behave like Spin.
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
navigate_frame(app, ctx, self.driver_mapping, axes, seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_ui_egui::space_mouse::{DeviceMotion, Motion};
|
||||
|
||||
fn raw(axes: Axes) -> Motion {
|
||||
DriverMapping::default().apply(axes)
|
||||
/// The logical axes a reading reaches. Logical Rz rolls the view and logical Ry turns it about
|
||||
/// its vertical (`navigation::device_camera` in solidcraft-ui-egui, tested there).
|
||||
fn rotation(mapping: DriverMapping, rotation: [i16; 3]) -> [f64; 3] {
|
||||
mapping.apply(Axes { translation: [0; 3], rotation }).rotation
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -519,69 +430,6 @@ mod tests {
|
||||
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();
|
||||
let before = cam;
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(cam.target, before.target);
|
||||
assert!(cam.distance < before.distance);
|
||||
assert_ne!(cam.yaw, before.yaw);
|
||||
assert_ne!(cam.pitch, before.pitch);
|
||||
assert_ne!(cam.roll, before.roll);
|
||||
let orientation = (cam.yaw, cam.pitch, cam.roll);
|
||||
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individual_axes_have_physical_spacemouse_meaning() {
|
||||
let before = Camera::default();
|
||||
|
||||
let mut slide = before;
|
||||
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(slide.target, before.target);
|
||||
assert_eq!(slide.distance, before.distance);
|
||||
|
||||
let mut push = before;
|
||||
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_eq!(push.target, before.target);
|
||||
assert!(push.distance < before.distance);
|
||||
|
||||
let mut lift = before;
|
||||
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(lift.target, before.target);
|
||||
assert_eq!(lift.distance, before.distance);
|
||||
|
||||
let mut tilt = before;
|
||||
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
|
||||
assert_ne!(tilt.pitch, before.pitch);
|
||||
|
||||
let mut roll = before;
|
||||
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
|
||||
assert_ne!(roll.yaw, before.yaw);
|
||||
|
||||
let mut twist = before;
|
||||
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(twist.roll, before.roll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_zoom_setting_applies_to_spacemouse() {
|
||||
let before = Camera::default();
|
||||
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
|
||||
let mut normal = before;
|
||||
navigate(&mut normal, motion, 0.02, 600.0, false, false);
|
||||
let mut reversed = before;
|
||||
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
|
||||
assert!(normal.distance < before.distance);
|
||||
assert!(reversed.distance > before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_3dxware_axis_swaps_and_reversals() {
|
||||
let profile = br#"
|
||||
@@ -601,7 +449,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
|
||||
fn without_a_profile_the_drivers_object_mode_axes_are_used() {
|
||||
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
|
||||
let object_mode = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
@@ -614,39 +462,15 @@ mod tests {
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(DriverMapping::without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
let before = Camera::default();
|
||||
let mut tilt = before;
|
||||
navigate(
|
||||
&mut tilt,
|
||||
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
|
||||
0.02,
|
||||
600.0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(tilt.roll, before.roll);
|
||||
let mut twist = before;
|
||||
navigate(
|
||||
&mut twist,
|
||||
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
|
||||
0.02,
|
||||
600.0,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_ne!(twist.yaw, before.yaw);
|
||||
assert_eq!(twist.roll, before.roll);
|
||||
} else {
|
||||
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
|
||||
}
|
||||
// On every platform (#1172 on Linux, #1380 on Windows).
|
||||
assert_eq!(without_profile(), profile);
|
||||
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
|
||||
assert_eq!(rotation(without_profile(), [0, 350, 0]), [0.0, 0.0, -1.0]);
|
||||
assert_eq!(rotation(without_profile(), [0, 0, 350]), [0.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
|
||||
fn mapped_roll_and_spin_reach_the_roll_and_turn_axes() {
|
||||
let profile = br#"
|
||||
<AppCfg><Devices><Device><AxisBank>
|
||||
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
|
||||
@@ -654,16 +478,90 @@ mod tests {
|
||||
</AxisBank></Device></Devices></AppCfg>
|
||||
"#;
|
||||
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
|
||||
let before = Camera::default();
|
||||
// The physical roll reaches the roll axis only, and the physical spin the turn only.
|
||||
assert_eq!(rotation(mapping, [0, 350, 0]), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(rotation(mapping, [0, 0, 350]), [0.0, -1.0, 0.0]);
|
||||
}
|
||||
|
||||
let mut physical_roll = before;
|
||||
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
|
||||
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
|
||||
assert_ne!(physical_roll.roll, before.roll);
|
||||
#[test]
|
||||
fn each_frame_hands_the_view_its_motion_through_the_drivers_mapping() {
|
||||
// The reader's latest tilt, and a profile that makes tilt roll: the frame's motion rolls.
|
||||
let now = Instant::now();
|
||||
let mut reading = Reading::new(now);
|
||||
reading.axes.rotation = [0, 350, 0];
|
||||
let mut mouse = SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
last_frame: now - Duration::from_millis(20),
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
};
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default());
|
||||
app.home.open = false;
|
||||
mouse.tick(&mut app, &Context::default());
|
||||
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
|
||||
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
|
||||
assert!(seconds > 0.0);
|
||||
}
|
||||
|
||||
let mut physical_spin = before;
|
||||
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
|
||||
assert_ne!(physical_spin.yaw, before.yaw);
|
||||
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
|
||||
fn wheel() -> egui::Event {
|
||||
egui::Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Line,
|
||||
delta: egui::vec2(0.0, -1.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse(reading: Reading) -> SpaceMouse {
|
||||
let now = Instant::now();
|
||||
SpaceMouse {
|
||||
shared: Arc::new(Mutex::new(reading)),
|
||||
last_frame: now,
|
||||
driver_mapping: DriverMapping::object_mode(),
|
||||
mapping_checked_at: now,
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_3dxware_makes_from_the_cap_is_dropped() {
|
||||
// The cap tilted forward: 3DxWare also sends wheel turns (#1380). They are dropped.
|
||||
let now = Instant::now();
|
||||
let mut tilted = Reading::new(now);
|
||||
tilted.axes.rotation = [-200, 0, 0];
|
||||
let mut moving = mouse(tilted);
|
||||
let mut raw = egui::RawInput { events: vec![wheel(), egui::Event::Zoom(1.1), egui::Event::Text("a".into())], ..Default::default() };
|
||||
moving.raw_input_hook(true, &mut raw);
|
||||
assert_eq!(raw.events, [egui::Event::Text("a".into())]);
|
||||
// With navigation off, the wheel is the cap's only way to move the view: kept.
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
moving.raw_input_hook(false, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
// The cap at rest: the wheel is the user's.
|
||||
let mut resting = mouse(Reading::new(now));
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
resting.raw_input_hook(true, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wheel_is_the_users_again_once_the_cap_has_been_still_a_moment() {
|
||||
let mut tilted = Reading::new(Instant::now());
|
||||
tilted.axes.rotation = [-200, 0, 0];
|
||||
let mut cap = mouse(tilted);
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
cap.raw_input_hook(true, &mut raw);
|
||||
assert!(raw.events.is_empty(), "dropped while the cap moves");
|
||||
// Let go, then wait out the quiet time.
|
||||
if let Ok(mut reading) = cap.shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs_f64(solidcraft_ui_egui::space_mouse::WHEEL_QUIET_S) + Duration::from_millis(60));
|
||||
let mut raw = egui::RawInput { events: vec![wheel()], ..Default::default() };
|
||||
cap.raw_input_hook(true, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1, "kept once the cap has been still");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -75,7 +75,7 @@ The CLI still supports arbitrary PNG/ICO exports with `solidcraft-cli icon`.
|
||||
| Linux, Nix, deb/rpm, tar, Flatpak, Snap | Copy canonical hicolor images; icon name stays `ai.storyteller.solidcraft` |
|
||||
| AppImage | Uses the Linux 256 px image and `.DirIcon` alias |
|
||||
| FreeBSD | Shares Linux hicolor images and freedesktop metadata |
|
||||
| Web | HTML/Trunk stays in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/` and uses the collected CSS; no favicon/PWA icon currently exists |
|
||||
| Web | HTML/Trunk and `manifest.webmanifest` stay in `apps/solidcraft-web/`; copies Japanese font/licence to `fonts/`, uses the collected CSS, and copies the Linux 32/256/512 px images to `icons/` for the favicon and installed app |
|
||||
| Docs and link previews | `xtask book` copies the shared card to the existing `/solidcraft/og.png` public path |
|
||||
|
||||
## Code and provenance
|
||||
|
||||
@@ -21,6 +21,19 @@ fn ellipse(x: f32, y: f32, rx: f32, ry: f32, a0: f32, a1: f32, n: usize) -> Vec<
|
||||
(0..=n).map(|i| a0 + (a1 - a0) * i as f32 / n as f32).map(|a| (x + rx * a.cos(), y + ry * a.sin())).collect()
|
||||
}
|
||||
|
||||
/// A gear's toothed outline about (x, y): `n` teeth from the root radius `root` out to `tip`,
|
||||
/// turned by `phase` radians.
|
||||
fn teeth(x: f32, y: f32, root: f32, tip: f32, n: usize, phase: f32) -> Vec<(f32, f32)> {
|
||||
let step = std::f32::consts::TAU / n as f32;
|
||||
let at = |a: f32, r: f32| (x + r * a.cos(), y - r * a.sin());
|
||||
(0..n)
|
||||
.flat_map(|k| {
|
||||
let a = phase + step * k as f32;
|
||||
[at(a - 0.3 * step, root), at(a - 0.15 * step, tip), at(a + 0.15 * step, tip), at(a + 0.3 * step, root)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Pen<'a> {
|
||||
p: &'a Painter,
|
||||
r: Rect,
|
||||
@@ -271,6 +284,13 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
|
||||
pen.line(&[(7.5, 10.0), (12.0, 14.5), (16.5, 10.0)], ink);
|
||||
pen.line(&[(4.0, 15.0), (4.0, 20.0), (20.0, 20.0), (20.0, 15.0)], ink);
|
||||
}
|
||||
// Agent chat: a speech bubble with three dots.
|
||||
"agent_chat" => {
|
||||
pen.closed(&[(4.0, 5.0), (20.0, 5.0), (20.0, 16.0), (11.0, 16.0), (6.5, 20.0), (7.5, 16.0), (4.0, 16.0)], ink);
|
||||
for x in [8.5, 12.0, 15.5] {
|
||||
pen.dot(x, 10.5, ink);
|
||||
}
|
||||
}
|
||||
// Help: a question mark in a ring.
|
||||
"help" => {
|
||||
pen.circle(12.0, 12.0, 9.0, Color32::TRANSPARENT, ink);
|
||||
@@ -470,6 +490,23 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
|
||||
}
|
||||
p.circle_filled(at(0.5), dot_r, a);
|
||||
}
|
||||
"infinite" => {
|
||||
// An infinite line (#1359): Construction's broken line, with an open arrowhead at
|
||||
// each end saying it runs on. Accent only, like Construction and Centerline.
|
||||
let w = line_type_stroke(r);
|
||||
let at = |t: f32| pen.at(3.0 + 16.0 * t, 19.0 - 16.0 * t);
|
||||
for (t0, t1) in [(0.0, 0.38), (0.62, 1.0)] {
|
||||
p.add(Shape::line(vec![at(t0), at(t1)], Stroke::new(w, a)));
|
||||
}
|
||||
// Each head: two short strokes back from the tip, either side of the line.
|
||||
let tip_len = 0.3;
|
||||
for (tip, back) in [(0.0f32, tip_len), (1.0, 1.0 - tip_len)] {
|
||||
let (t, b) = (at(tip), at(back));
|
||||
let along = b - t;
|
||||
let side = egui::vec2(-along.y, along.x) * 0.55;
|
||||
p.add(Shape::line(vec![b + side, t, b - side], Stroke::new(w, a)));
|
||||
}
|
||||
}
|
||||
"c_hv" => {
|
||||
pen.line(&[(4.0, 18.0), (20.0, 18.0)], a);
|
||||
pen.line(&[(6.0, 4.0), (6.0, 14.0)], a);
|
||||
@@ -725,6 +762,20 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
|
||||
pen.closed(&[(21.0, 6.0), (15.0, 10.0), (15.0, 18.0), (21.0, 18.0)], a);
|
||||
}
|
||||
"sketch_circ_pattern" => paint(p, r, "pattern_circ", ink, Color32::TRANSPARENT, a),
|
||||
"pattern_flip" => {
|
||||
// Opposing arrows show reversal, with a short reference tick between them.
|
||||
pen.arrow((4.0, 7.0), (20.0, 7.0), a);
|
||||
pen.arrow((20.0, 17.0), (4.0, 17.0), ink);
|
||||
pen.line(&[(12.0, 10.0), (12.0, 14.0)], ink);
|
||||
}
|
||||
"pattern_center" => {
|
||||
// An original on a reference axis, with one equally spaced copy on either side.
|
||||
pen.line(&[(12.0, 3.0), (12.0, 21.0)], a);
|
||||
pen.line(&[(4.0, 12.0), (20.0, 12.0)], ink);
|
||||
for x in [4.0, 12.0, 20.0] {
|
||||
pen.circle(x, 12.0, 2.4, fill, if x == 12.0 { a } else { ink });
|
||||
}
|
||||
}
|
||||
"sketch_rect_pattern" => paint(p, r, "pattern_rect", ink, Color32::TRANSPARENT, a),
|
||||
"line_mid" => {
|
||||
pen.line(&[(3.0, 19.0), (21.0, 5.0)], a);
|
||||
@@ -1242,6 +1293,14 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
|
||||
pen.arc(12.0, 12.0, 8.5, 0.3, 4.6, a);
|
||||
pen.arrow((11.0, 20.4), (14.5, 20.3), a);
|
||||
}
|
||||
// A crank on its pivot and the closed path its far end draws (accent).
|
||||
"trace" => {
|
||||
pen.curve([(15.0, 13.0), (22.0, 13.0), (23.0, 3.0), (15.0, 3.0)], a);
|
||||
pen.curve([(15.0, 3.0), (8.0, 3.0), (9.0, 13.0), (15.0, 13.0)], a);
|
||||
pen.line(&[(5.0, 20.0), (15.0, 13.0)], ink);
|
||||
pen.circle(5.0, 20.0, 2.4, pen.fill, ink);
|
||||
pen.dot(15.0, 13.0, a);
|
||||
}
|
||||
"motion_link" => {
|
||||
pen.circle(7.0, 14.0, 4.5, pen.fill, ink);
|
||||
pen.circle(17.5, 9.0, 3.0, pen.fill, ink);
|
||||
@@ -1249,6 +1308,36 @@ pub(crate) fn paint_with(p: &Painter, r: Rect, name: &str, ink: Color32, fill: C
|
||||
pen.dot(7.0, 14.0, a);
|
||||
pen.dot(17.5, 9.0, a);
|
||||
}
|
||||
// Couplings: the driven part in the accent.
|
||||
"gear" => {
|
||||
pen.circle(9.0, 14.0, 5.2, pen.fill, Color32::TRANSPARENT);
|
||||
pen.closed(&teeth(9.0, 14.0, 5.2, 6.8, 9, 0.0), ink);
|
||||
pen.dot(9.0, 14.0, ink);
|
||||
pen.circle(17.6, 7.4, 3.2, pen.fill, Color32::TRANSPARENT);
|
||||
pen.closed(&teeth(17.6, 7.4, 3.2, 4.6, 6, 0.3), a);
|
||||
pen.dot(17.6, 7.4, a);
|
||||
}
|
||||
"rack_pinion" => {
|
||||
pen.circle(12.0, 7.4, 4.2, pen.fill, Color32::TRANSPARENT);
|
||||
pen.closed(&teeth(12.0, 7.4, 4.2, 5.8, 8, 0.2), ink);
|
||||
pen.dot(12.0, 7.4, ink);
|
||||
let mut rack = vec![(2.0, 18.0)];
|
||||
for k in 0..5 {
|
||||
let x = 2.5 + 4.0 * k as f32;
|
||||
rack.extend([(x, 14.3), (x + 1.0, 12.8), (x + 2.2, 12.8), (x + 3.2, 14.3)]);
|
||||
}
|
||||
rack.extend([(22.0, 14.3), (22.0, 18.0)]);
|
||||
pen.closed(&rack, a);
|
||||
pen.arrow((7.0, 21.5), (17.5, 21.5), a);
|
||||
}
|
||||
"screw" => {
|
||||
pen.poly(&[(9.5, 2.0), (14.5, 2.0), (14.5, 22.0), (9.5, 22.0)], pen.fill, ink);
|
||||
for y in [5.0, 8.0, 17.0, 20.0] {
|
||||
pen.line(&[(9.5, y), (14.5, y - 2.0)], ink);
|
||||
}
|
||||
pen.poly(&[(6.0, 10.0), (18.0, 10.0), (18.0, 15.0), (6.0, 15.0)], pen.fill, a);
|
||||
pen.arrow((21.0, 17.0), (21.0, 6.0), a);
|
||||
}
|
||||
"interference" => {
|
||||
pen.closed(&[(3.0, 5.0), (14.0, 5.0), (14.0, 16.0), (3.0, 16.0)], ink);
|
||||
pen.poly(&[(9.0, 10.0), (14.0, 10.0), (14.0, 16.0), (9.0, 16.0)], ic.interference, ic.interference);
|
||||
@@ -1432,7 +1521,7 @@ mod tests {
|
||||
/// theme's construction orange) and must not change with `ink`.
|
||||
#[test]
|
||||
fn construction_and_centerline_icons_follow_the_callers_accent_only() {
|
||||
for name in ["construction", "centerline"] {
|
||||
for name in ["construction", "centerline", "infinite"] {
|
||||
let base = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(1, 2, 3));
|
||||
let other_accent = drawn(name, Color32::BLACK, Color32::WHITE, Color32::from_rgb(4, 5, 6));
|
||||
assert_ne!(base, other_accent, "{name} icon must change with the caller's accent colour");
|
||||
|
||||
+899
-611
File diff suppressed because it is too large.
Load diff
+791
-491
File diff suppressed because it is too large.
Load diff
+618
-370
File diff suppressed because it is too large.
Load diff
+791
-491
File diff suppressed because it is too large.
Load diff
+899
-611
File diff suppressed because it is too large.
Load diff
+545
-423
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+464
-368
File diff suppressed because it is too large.
Load diff
+797
-497
File diff suppressed because it is too large.
Load diff
@@ -93,12 +93,7 @@ impl Tokens {
|
||||
bend_up: Color32::from_rgb(30, 140, 50),
|
||||
bend_down: Color32::from_rgb(200, 90, 20),
|
||||
interference: Color32::from_rgb(235, 45, 40),
|
||||
analysis: AnalysisColors {
|
||||
draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]],
|
||||
access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]],
|
||||
curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]],
|
||||
hatch: false,
|
||||
},
|
||||
analysis: AnalysisColors::DEFAULT,
|
||||
icons: IconColors {
|
||||
sheet: Color32::from_rgb(170, 200, 240),
|
||||
sheet_2: Color32::from_rgb(150, 185, 235),
|
||||
|
||||
+12
-26
@@ -10,16 +10,17 @@
|
||||
//!
|
||||
//! What a field is, is written once, on the field, with `#[walk(...)]`:
|
||||
//!
|
||||
//! - no attribute: the field's own type walks itself (`impl Walk`): a plane, a nested struct, a
|
||||
//! list of them, or nothing for names, flags and numbers. A bare `Vec3` has no walk, so a point
|
||||
//! that does not say its role does not compile;
|
||||
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (a `String`, an
|
||||
//! optional one, a list or an array of them, or `(f64, String)` pairs). The label is any
|
||||
//! - no attribute: the field's own type walks itself (`impl Walk`): a reference (`SketchId`,
|
||||
//! `BodyName`, `FeatureName`), a point with its role (`FacePick`, `Position`, `Dir`), a plane, a
|
||||
//! nested struct, a list of them, or nothing for names, flags and numbers. An `Expr` or a bare
|
||||
//! `Vec3` has no walk, so an expression or a point that does not say what it is does not
|
||||
//! compile;
|
||||
//! - `length = label`, `angle = label`, `unitless = label`: an expression input (an `Expr`, an
|
||||
//! optional one, a list or an array of them, or `(f64, Expr)` pairs). The label is any
|
||||
//! expression: a `&'static str`, an array of them (one per element), or a closure
|
||||
//! `|i: usize, n: usize| -> &'static str`; it may read the other fields;
|
||||
//! - `sketch`, `feature`, `body`: a reference (an id, or a name, optional or a list);
|
||||
//! - `pick`, `pinned`, `pos`, `dir`: a point (`Vec3`, optional or a list) with that role;
|
||||
//! `pick_if = cond`: a pick when `cond` holds, else a position;
|
||||
//! - `pinned`: a face pick that stays where it is when the feature moves;
|
||||
//! `pick_if = cond`: a face pick when `cond` holds, else a position;
|
||||
//! - `placement`: a primitive's placement point (its X, Y and Z expressions are not inputs);
|
||||
//! - `profiles_of = sketch_field`, optionally with `when = cond`: a profile selection;
|
||||
//! - `after = field`: visit this field right after `field` instead of in declaration order (where
|
||||
@@ -60,8 +61,6 @@ struct Roles {
|
||||
geo: Option<Geo>,
|
||||
/// The kind (`Length`, `Angle`, `Unitless`) and the label.
|
||||
expr: Option<(&'static str, Expr)>,
|
||||
/// `Sketch`, `Feature` or `Body`.
|
||||
reference: Option<&'static str>,
|
||||
after: Option<Ident>,
|
||||
/// The positions inside this (nested) value pick faces here.
|
||||
picks_faces: bool,
|
||||
@@ -70,7 +69,7 @@ struct Roles {
|
||||
impl Roles {
|
||||
/// No role given: the field's type walks itself.
|
||||
fn nested(&self) -> bool {
|
||||
self.geo.is_none() && self.expr.is_none() && self.reference.is_none()
|
||||
self.geo.is_none() && self.expr.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,10 +81,7 @@ fn roles(field: &syn::Field) -> syn::Result<Roles> {
|
||||
attr.parse_nested_meta(|meta| {
|
||||
let name = meta.path.get_ident().map(Ident::to_string).unwrap_or_default();
|
||||
match name.as_str() {
|
||||
"pick" => r.geo = Some(Geo::Points("Pick")),
|
||||
"pinned" => r.geo = Some(Geo::Points("Pinned")),
|
||||
"pos" => r.geo = Some(Geo::Points("Pos")),
|
||||
"dir" => r.geo = Some(Geo::Points("Dir")),
|
||||
"placement" => r.geo = Some(Geo::Placement),
|
||||
"pick_if" => r.geo = Some(Geo::PickIf(meta.value()?.parse()?)),
|
||||
"profiles_of" => sketch = Some(meta.value()?.parse::<Ident>()?),
|
||||
@@ -93,9 +89,6 @@ fn roles(field: &syn::Field) -> syn::Result<Roles> {
|
||||
"length" => r.expr = Some(("Length", meta.value()?.parse()?)),
|
||||
"angle" => r.expr = Some(("Angle", meta.value()?.parse()?)),
|
||||
"unitless" => r.expr = Some(("Unitless", meta.value()?.parse()?)),
|
||||
"sketch" => r.reference = Some("Sketch"),
|
||||
"feature" => r.reference = Some("Feature"),
|
||||
"body" => r.reference = Some("Body"),
|
||||
"after" => r.after = Some(meta.value()?.parse()?),
|
||||
"picks_faces" => r.picks_faces = true,
|
||||
_ => return Err(meta.error("unknown walk role")),
|
||||
@@ -157,14 +150,7 @@ fn walk_field(b: &Ident, r: &Roles, which: Which, mutable: bool) -> Tokens {
|
||||
_ => quote!(),
|
||||
}
|
||||
}
|
||||
Which::Refs => match r.reference {
|
||||
Some(kind) => {
|
||||
let t = if kind == "Sketch" { quote!(u64) } else { quote!(String) };
|
||||
let kind = Ident::new(kind, Span::call_site());
|
||||
quote! { #w::Each::<#t>::#each(#b, &mut |x| f(#w::Ref::#kind(x))); }
|
||||
}
|
||||
None => quote!(),
|
||||
},
|
||||
Which::Refs => quote!(),
|
||||
Which::Geometry => match &r.geo {
|
||||
Some(Geo::Points(v)) => {
|
||||
let v = Ident::new(v, Span::call_site());
|
||||
@@ -209,7 +195,7 @@ fn pre_values(b: &Ident, r: &Roles, which: Which) -> Tokens {
|
||||
quote! {
|
||||
let #wh: bool = #when;
|
||||
let mut #id: Option<u64> = None;
|
||||
#w::Each::<u64>::each(&*#sketch, &mut |s| #id = Some(*s));
|
||||
#w::Each::<crate::SketchId>::each(&*#sketch, &mut |s| #id = Some(s.0));
|
||||
}
|
||||
}
|
||||
_ => quote!(),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Lists the design-format changes in `src/format/`: one file per change, in `v<N>/` once a
|
||||
//! release fixed its number, else in `pending/` (see `src/format/pending/README.md`). The list is
|
||||
//! generated so that a change adds a file and never edits a line another change also edits.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The last format whose upgrade steps are written out in `format.rs` (`JSON_STEPS`).
|
||||
const HAND_WRITTEN: u32 = 22;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/format");
|
||||
println!("cargo:rerun-if-changed={}", root.display());
|
||||
let mut folded: Vec<(u32, PathBuf)> = std::fs::read_dir(&root)
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", root.display()))
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
let version = path.file_name()?.to_str()?.strip_prefix('v')?.parse().ok()?;
|
||||
path.is_dir().then_some((version, path))
|
||||
})
|
||||
.collect();
|
||||
folded.sort();
|
||||
for (i, (version, _)) in folded.iter().enumerate() {
|
||||
let expected = HAND_WRITTEN + 1 + i as u32;
|
||||
assert_eq!(*version, expected, "src/format/: expected v{expected}/ next, found v{version}/ (versions are contiguous)");
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut mods = String::new();
|
||||
out.push_str("/// The changes of each format after the hand-written ones, oldest first.\nconst FOLDED: &[(u32, &[Part])] = &[\n");
|
||||
for (version, dir) in &folded {
|
||||
let _ = writeln!(out, " ({version}, &[");
|
||||
parts(dir, &format!("v{version}"), &mut out, &mut mods);
|
||||
out.push_str(" ]),\n");
|
||||
}
|
||||
out.push_str("];\n\n/// The changes no release has a number for yet (`src/format/pending/`).\nconst PENDING: &[Part] = &[\n");
|
||||
parts(&root.join("pending"), "pending", &mut out, &mut mods);
|
||||
out.push_str("];\n");
|
||||
out.push_str(&mods);
|
||||
let dest = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("format_parts.rs");
|
||||
std::fs::write(dest, out).unwrap();
|
||||
}
|
||||
|
||||
/// The `Part`s of the change files in `dir`, in file-name order, and a module for each.
|
||||
fn parts(dir: &Path, prefix: &str, out: &mut String, mods: &mut String) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut files: Vec<PathBuf> = entries.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x == "rs")).collect();
|
||||
files.sort();
|
||||
for path in files {
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default().to_string();
|
||||
assert!(
|
||||
!name.is_empty() && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
|
||||
"{}: name a format change file with lowercase letters, digits and dashes",
|
||||
path.display()
|
||||
);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(text.trim_start().starts_with("//!"), "{}: start a format change with a `//!` note", path.display());
|
||||
let module = format!("{prefix}_{}", name.replace('-', "_"));
|
||||
let step = if text.contains("fn step(") { format!("Some({module}::step)") } else { "None".into() };
|
||||
let _ = writeln!(out, " Part {{ name: {name:?}, step: {step} }},");
|
||||
let _ = writeln!(mods, "#[path = {:?}]\nmod {module};", path.display().to_string());
|
||||
}
|
||||
}
|
||||
@@ -136,11 +136,16 @@ fn map_plane(m: &Mat, p: &Plane) -> Plane {
|
||||
fn map_plane_ref(m: &Mat, p: &mut PlaneRef) {
|
||||
match p {
|
||||
PlaneRef::Custom { plane } => *plane = map_plane(m, plane),
|
||||
PlaneRef::Placed { origin, x_dir, y_dir } => {
|
||||
map_point_expr(m, origin);
|
||||
*x_dir = apply_vector(m, *x_dir);
|
||||
*y_dir = apply_vector(m, *y_dir);
|
||||
}
|
||||
PlaneRef::Face { plane, at, .. } => {
|
||||
*plane = map_plane(m, plane);
|
||||
*at = apply_point(m, *at);
|
||||
}
|
||||
PlaneRef::Offset { base, .. } => map_plane_ref(m, base),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::Flipped { base, .. } => map_plane_ref(m, base),
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, .. } => {
|
||||
map_plane_ref(m, base);
|
||||
*axis_origin = apply_point(m, *axis_origin);
|
||||
@@ -171,7 +176,7 @@ fn map_geo_ref(m: &Mat, g: &mut crate::construct::GeoRef) {
|
||||
/// Map a point given as expressions by `m`: numbers stay numbers; a coordinate that is an
|
||||
/// expression stays one (`m · (x, y, z) + t`, written out), so it still follows its parameters.
|
||||
pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
|
||||
let num = |e: &String| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
let num = |e: &crate::Expr| e.trim().parse::<f64>().ok().filter(|v| v.is_finite());
|
||||
if let [Some(x), Some(y), Some(z)] = [num(&p[0]), num(&p[1]), num(&p[2])] {
|
||||
*p = crate::point_expr(apply_point(m, Vec3::new(x, y, z)));
|
||||
return;
|
||||
@@ -197,7 +202,7 @@ pub fn map_point_expr(m: &Mat, p: &mut crate::PointExpr) {
|
||||
} else if constant.abs() >= 1e-12 {
|
||||
e = format!("{e} {} {} mm", if constant < 0.0 { '-' } else { '+' }, constant.abs());
|
||||
}
|
||||
*out = e;
|
||||
*out = e.into();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,13 @@ mod tests {
|
||||
(vec!["bad\nname".into()], 0, crate::IDENTITY),
|
||||
(vec!["Block".into()], 0, scaled),
|
||||
] {
|
||||
assert!(doc.add_feature(crate::FeatureKind::BodyTransfer { bodies, from, to: 0, transform }, None).is_err());
|
||||
assert!(
|
||||
doc.add_feature(
|
||||
crate::FeatureKind::BodyTransfer { bodies: bodies.into_iter().map(crate::BodyName).collect(), from, to: 0, transform },
|
||||
None
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,10 @@ fn remap_plane(
|
||||
return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => remap_plane(base, ids, names, bodies, depth + 1)?,
|
||||
PlaneRef::Construction { name } => mapped_name(name, names),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
remap_plane(base, ids, names, bodies, depth + 1)?
|
||||
}
|
||||
PlaneRef::Construction { name } => mapped_name(&mut name.0, names),
|
||||
PlaneRef::Face { name, anchor, .. } => {
|
||||
if let Some(name) = name {
|
||||
*name = remap_topology(name, ids, bodies);
|
||||
@@ -221,10 +223,10 @@ impl crate::Feature {
|
||||
*n = remap_topology(n, ids, bodies);
|
||||
}
|
||||
for (_, e, _) in self.kind.inputs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
*e = clone_expression(e, parameters).into();
|
||||
}
|
||||
for e in self.kind.point_expr_mut().into_iter().flatten() {
|
||||
*e = clone_expression(e, parameters);
|
||||
for e in self.kind.placement_exprs_mut() {
|
||||
*e = clone_expression(e, parameters).into();
|
||||
}
|
||||
self.kind.remap_bodies(bodies);
|
||||
match &mut self.kind {
|
||||
@@ -235,6 +237,9 @@ impl crate::Feature {
|
||||
mapped_name(n, parameters);
|
||||
}
|
||||
}
|
||||
for e in sketch.text_exprs_mut() {
|
||||
*e = clone_expression(e, parameters);
|
||||
}
|
||||
for link in &mut sketch.links {
|
||||
remap_link(&mut link.source, ids, names, bodies, 0);
|
||||
for n in link.face_name.iter_mut().chain(&mut link.edge_name) {
|
||||
@@ -280,7 +285,7 @@ fn checked_plane(p: &PlaneRef, depth: usize) -> crate::Result<()> {
|
||||
return Err(crate::DocError::Invalid("copy reference nesting exceeds 64".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => checked_plane(base, depth + 1)?,
|
||||
PlaneRef::Midplane { a, b } | PlaneRef::TwoEdges { a, b } => {
|
||||
checked_geo(a, depth + 1)?;
|
||||
checked_geo(b, depth + 1)?;
|
||||
|
||||
@@ -148,6 +148,35 @@ pub(crate) fn extrude_cap_bindings(sketch: &SolvedSketch, bodies: &[solidcraft_k
|
||||
Some(bindings)
|
||||
}
|
||||
|
||||
/// Cap signatures for a thin extrude (#1519). A thin wall's caps are bounded by offset curves
|
||||
/// the sketch doesn't have, so [`extrude_cap_bindings`] can never certify them. A wall built
|
||||
/// from one region is signed by the curves it follows instead: the cap of this extrude whose
|
||||
/// wall runs along them. That is the signature its solid caps had when the wall follows the
|
||||
/// whole profile (a rectangle's four lines), so a sketch attached to a solid cap keeps its face
|
||||
/// when the extrude is made thin, and the other way round. Several regions (walls apart, or one
|
||||
/// meeting itself) are left uncertified: they keep their history names.
|
||||
pub(crate) fn thin_extrude_cap_bindings(curves: &[String], regions: usize, bodies: &[solidcraft_kernel::Body]) -> CapBindings {
|
||||
use solidcraft_kernel::FaceSource;
|
||||
let mut outer = curves.to_vec();
|
||||
outer.sort();
|
||||
outer.dedup();
|
||||
let signed = regions == 1 && bodies.len() == 1 && valid_signature(&outer, &[]);
|
||||
bodies
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let sources: Vec<_> = body.face_sources().into_iter().flatten().collect();
|
||||
let ends =
|
||||
|high: bool| sources.iter().filter(|source| matches!(source, Some(FaceSource::ExtrudeCap { high: h, .. }) if *h == high)).count();
|
||||
// One cap at each end, or none of them is certified.
|
||||
let unique = ends(true) <= 1 && ends(false) <= 1;
|
||||
sources
|
||||
.iter()
|
||||
.map(|source| (signed && unique && matches!(source, Some(FaceSource::ExtrudeCap { .. }))).then(|| (outer.clone(), Vec::new())))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum FaceCertificate {
|
||||
|
||||
@@ -17,7 +17,7 @@ fn shell_document() -> (Document, u64, u64) {
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(5., 6., 10.)],
|
||||
faces: vec![crate::FacePick(Vec3::new(5., 6., 10.))],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
@@ -119,7 +119,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
sketch: crate::SketchId(sk),
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
@@ -282,7 +282,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
sketch: crate::SketchId(sk),
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
@@ -344,7 +344,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
let shell = doc
|
||||
.add_feature(
|
||||
FeatureKind::Shell {
|
||||
faces: vec![Vec3::new(0., 0., 30.)],
|
||||
faces: vec![crate::FacePick(Vec3::new(0., 0., 30.))],
|
||||
thickness: "wall".into(),
|
||||
body: Some("Case".into()),
|
||||
direction: "inside".into(),
|
||||
@@ -432,7 +432,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
sketch: crate::SketchId(sk),
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
@@ -557,7 +557,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
sketch: crate::SketchId(sk),
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
operation: crate::Operation::NewBody,
|
||||
|
||||
@@ -82,7 +82,7 @@ pub enum PointDef {
|
||||
/// Where an edge (line) crosses a plane.
|
||||
EdgeAndPlane { edge: GeoRef, plane: GeoRef },
|
||||
/// At a fraction `t` (0…1) of the way along an edge or sketch curve.
|
||||
AlongPath { path: GeoRef, t: String },
|
||||
AlongPath { path: GeoRef, t: crate::Expr },
|
||||
}
|
||||
|
||||
/// Resolved construction geometry, kept in the model state by feature.
|
||||
@@ -633,7 +633,7 @@ impl PlaneRef {
|
||||
PlaneRef::ThreePoints { a, b, c } => vec![a.as_mut(), b.as_mut(), c.as_mut()],
|
||||
PlaneRef::Tangent { face, .. } => vec![face.as_mut()],
|
||||
PlaneRef::AlongPath { path, .. } => vec![path.as_mut()],
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => base.refs_mut(),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => base.refs_mut(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,57 @@ impl DisplayQuality {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most refinement steps view-dependent display refinement takes past the chosen level (#1320):
|
||||
/// each halves the chord tolerance, so the finest is 1/16 of the chosen level's.
|
||||
pub const MAX_REFINE: u8 = 4;
|
||||
|
||||
/// Screen-space error (physical pixels) above which a body is refined: a facet's sag shows.
|
||||
pub const REFINE_PX: f64 = 1.0;
|
||||
/// Error a refinement aims for (below [`REFINE_PX`], so a body just refined is not refined again
|
||||
/// at the next small zoom step).
|
||||
pub const TARGET_PX: f64 = 0.75;
|
||||
/// Error below which a refined body steps back toward the chosen level. A refinement lands
|
||||
/// between half [`TARGET_PX`] and [`TARGET_PX`], and a step back between this and twice this, so
|
||||
/// zooming out or in by up to 2× from either never flips the level back.
|
||||
pub const COARSEN_PX: f64 = 0.175;
|
||||
|
||||
impl DisplayQuality {
|
||||
/// Chord tolerance (mm) of a body of this size refined `k` steps past this level: halved
|
||||
/// per step.
|
||||
pub fn refined_chord(self, size: f64, k: u8) -> f64 {
|
||||
self.chord(size) / f64::from(1u32 << k.min(MAX_REFINE))
|
||||
}
|
||||
|
||||
/// Facet angle (radians) `k` steps past this level: an arc's sag goes with the square of its
|
||||
/// angle, so the angle shrinks by √2 per step, keeping pace with the chord.
|
||||
pub fn refined_angle(self, k: u8) -> f64 {
|
||||
self.angle() / std::f64::consts::SQRT_2.powi(i32::from(k.min(MAX_REFINE)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The refinement step a body should be drawn at (0: the chosen level), from `err_px`: the
|
||||
/// screen-space error of the chosen level, its chord tolerance in physical pixels where the body
|
||||
/// is nearest the eye (0 for a body off screen or too small to matter). `current` is the step
|
||||
/// drawn now; steps halve the error. A body is refined once its error passes [`REFINE_PX`], to the
|
||||
/// first step under [`TARGET_PX`], and steps back only once the error falls under [`COARSEN_PX`],
|
||||
/// to the coarsest step that stays under twice that: the band between keeps an orbit or a small
|
||||
/// zoom from flipping levels. Never past `max` (at most [`MAX_REFINE`]).
|
||||
pub fn refinement_for(err_px: f64, current: u8, max: u8) -> u8 {
|
||||
let max = max.min(MAX_REFINE);
|
||||
let current = current.min(max);
|
||||
if !(err_px.is_finite() && err_px > 0.0) {
|
||||
return 0;
|
||||
}
|
||||
let err = |k: u8| err_px / f64::from(1u32 << k);
|
||||
if err(current) > REFINE_PX {
|
||||
(current..=max).find(|&k| err(k) <= TARGET_PX).unwrap_or(max)
|
||||
} else if err(current) < COARSEN_PX {
|
||||
(0..=current).find(|&k| err(k) <= 2.0 * COARSEN_PX).unwrap_or(current)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT: AtomicU8 = AtomicU8::new(1);
|
||||
|
||||
/// The level the viewport draws at (process-wide; set from the preferences).
|
||||
@@ -140,4 +191,58 @@ mod tests {
|
||||
plan.vertex_count()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refinement_follows_the_screen_space_error_with_hysteresis() {
|
||||
// Off screen or tiny: the chosen level.
|
||||
assert_eq!(refinement_for(0.0, 3, MAX_REFINE), 0);
|
||||
assert_eq!(refinement_for(f64::NAN, 2, MAX_REFINE), 0);
|
||||
// Fine already: stays.
|
||||
assert_eq!(refinement_for(0.6, 0, MAX_REFINE), 0);
|
||||
assert_eq!(refinement_for(1.0, 0, MAX_REFINE), 0);
|
||||
// Past a pixel: the first step under the target (each halves the error).
|
||||
assert_eq!(refinement_for(1.2, 0, MAX_REFINE), 1);
|
||||
assert_eq!(refinement_for(5.0, 0, MAX_REFINE), 3);
|
||||
assert_eq!(refinement_for(5.0, 1, MAX_REFINE), 3);
|
||||
// Capped.
|
||||
assert_eq!(refinement_for(1e6, 0, MAX_REFINE), MAX_REFINE);
|
||||
assert_eq!(refinement_for(1e6, 0, 2), 2);
|
||||
assert_eq!(refinement_for(1e6, 9, 99), MAX_REFINE);
|
||||
// Zooming back out: kept until the error is well under a pixel, then the coarsest step
|
||||
// that stays under half a pixel.
|
||||
assert_eq!(refinement_for(5.0, 3, MAX_REFINE), 3);
|
||||
assert_eq!(refinement_for(2.5, 3, MAX_REFINE), 3, "0.31 px at step 3: in the band");
|
||||
assert_eq!(refinement_for(1.2, 3, MAX_REFINE), 2, "0.15 px at step 3: down to 0.3 px");
|
||||
assert_eq!(refinement_for(0.05, 3, MAX_REFINE), 0);
|
||||
// No flicker: any error, once settled, gives the same step again, and a zoom by up to
|
||||
// 2x either way from where a level was chosen keeps it.
|
||||
let mut e = 0.01;
|
||||
while e < 1e4 {
|
||||
for start in 0..=MAX_REFINE {
|
||||
let k = refinement_for(e, start, MAX_REFINE);
|
||||
assert_eq!(refinement_for(e, k, MAX_REFINE), k, "error {e}, from {start}");
|
||||
// Settled by zooming in (from the chosen level) or out (from the finest).
|
||||
if start == 0 || start == MAX_REFINE {
|
||||
for f in [0.5, 0.7, 1.0, 1.4, 2.0] {
|
||||
let back = refinement_for(e * f, k, MAX_REFINE);
|
||||
let zoomed_in = f >= 1.0;
|
||||
assert!(
|
||||
back == k || (zoomed_in && back > k && start == 0) || (!zoomed_in && back < k && start == MAX_REFINE),
|
||||
"error {e} x{f} from {start}: {k} -> {back}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
e *= 1.13;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refined_steps_halve_the_chord() {
|
||||
let q = DisplayQuality::Normal;
|
||||
assert_eq!(q.refined_chord(200.0, 0), q.chord(200.0));
|
||||
assert_eq!(q.refined_chord(200.0, 2), q.chord(200.0) / 4.0);
|
||||
assert_eq!(q.refined_chord(200.0, 99), q.chord(200.0) / 16.0);
|
||||
assert!((q.refined_angle(2) - q.angle() / 2.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
+573
-390
File diff suppressed because it is too large.
Load diff
+368
-148
File diff suppressed because it is too large.
Load diff
+151
-61
@@ -10,7 +10,7 @@ use solidcraft_geom::{Loop2, Seg2};
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?;
|
||||
let regions = select_profiles(ss, profiles)?;
|
||||
let d = val(vals, depth, Kind::Length)?;
|
||||
if !(d > 0.0) {
|
||||
@@ -35,7 +35,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
crate::apply_vector(&sketch_transform, ss.plane.y),
|
||||
)
|
||||
.ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?;
|
||||
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at);
|
||||
let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at.0);
|
||||
let name = f
|
||||
.face_names
|
||||
.first()
|
||||
@@ -67,10 +67,10 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
if st.bodies.is_empty() {
|
||||
return Err(DocError::Invalid("there is no body to emboss".into()));
|
||||
}
|
||||
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, targets)
|
||||
apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
if !(t > 0.0) {
|
||||
return Err(DocError::Invalid("the rib thickness must be positive".into()));
|
||||
@@ -95,17 +95,18 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
FeatureKind::ReplaceFace { faces, target, body } => {
|
||||
let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?;
|
||||
let tn = tp.normal();
|
||||
let i = body_at(st, body, faces)?;
|
||||
let i = body_at(st, body, &crate::plain(faces))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let mut b = mb.body.clone();
|
||||
for p in faces {
|
||||
let n = planar_normal_at(&b, *p).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
|
||||
let n =
|
||||
planar_normal_at(&b, p.0).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?;
|
||||
if n.dot(tn).abs() < 1.0 - 1e-9 {
|
||||
return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into()));
|
||||
}
|
||||
let d = (tp.origin - *p).dot(n);
|
||||
let d = (tp.origin - p.0).dot(n);
|
||||
if d.abs() > 1e-9 {
|
||||
b = kernel::offset_faces(&b, &[*p], d)?;
|
||||
b = kernel::offset_faces(&b, &[p.0], d)?;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
@@ -115,17 +116,17 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
|
||||
let mut m = translation(Vec3::ZERO);
|
||||
if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) {
|
||||
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
|
||||
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
|
||||
let want = if *flip { b } else { b * -1.0 };
|
||||
let axis = a.cross(want);
|
||||
let ang = axis.len().atan2(a.dot(want));
|
||||
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
|
||||
if ang.abs() > 1e-12 {
|
||||
m = rotation(*from, axis, ang);
|
||||
m = rotation(from.0, axis, ang);
|
||||
}
|
||||
}
|
||||
let d = *to - *from;
|
||||
let d = to.0 - from.0;
|
||||
m[3][0] += d.x;
|
||||
m[3][1] += d.y;
|
||||
m[3][2] += d.z;
|
||||
@@ -150,7 +151,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
return Err(DocError::Unknown(format!("body `{n}`")));
|
||||
}
|
||||
}
|
||||
st.bodies.retain(|b| !bodies.contains(&b.name));
|
||||
st.bodies.retain(|b| !bodies.iter().any(|t| *t == b.name));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not evaluated here", f.name))),
|
||||
@@ -331,7 +332,7 @@ fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
}
|
||||
|
||||
/// The sheet a feature works on: by body name, else the one with an edge nearest the pick.
|
||||
fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Result<usize> {
|
||||
fn sheet_for(st: &ModelState, body: &Option<crate::BodyName>, pick: Option<Vec3>) -> Result<usize> {
|
||||
if let Some(n) = body {
|
||||
return st.sheets.iter().position(|s| &s.body == n).ok_or_else(|| DocError::Invalid(format!("`{n}` is not a sheet metal body")));
|
||||
}
|
||||
@@ -391,7 +392,7 @@ pub(super) fn sheet_eval(
|
||||
use crate::sheet;
|
||||
match &f.kind {
|
||||
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
@@ -425,7 +426,7 @@ pub(super) fn sheet_eval(
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
|
||||
@@ -449,8 +450,8 @@ pub(super) fn sheet_eval(
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().map(|e| e.point))?;
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body, corner_relief_shape, corner_relief } => {
|
||||
let i = sheet_for(st, body, edges.first().map(|e| e.point.0))?;
|
||||
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
|
||||
// Each edge's own height and angle, else the feature's.
|
||||
let mut own = Vec::with_capacity(edges.len());
|
||||
@@ -472,18 +473,32 @@ pub(super) fn sheet_eval(
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
// The corner relief these flanges ask for in place of the rule's.
|
||||
let relief = sheet::ReliefOverride {
|
||||
shape: match corner_relief_shape {
|
||||
Some(name) => Some(
|
||||
sheet::ReliefShape::parse(name)
|
||||
.ok_or_else(|| DocError::Invalid(format!("unknown corner relief shape `{name}` (notch, round, rectangular or tear)")))?,
|
||||
),
|
||||
None => None,
|
||||
},
|
||||
size: match corner_relief {
|
||||
Some(e) => Some(val(vals, e, Kind::Length)?),
|
||||
None => None,
|
||||
},
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
|
||||
}
|
||||
let points: Vec<Vec3> = edges.iter().map(|e| e.point).collect();
|
||||
let points: Vec<Vec3> = edges.iter().map(|e| e.point.0).collect();
|
||||
let each: Vec<sheet::EdgeFlange> = sheet_picks(sh, f, &points, warning)
|
||||
.into_iter()
|
||||
.zip(own)
|
||||
.map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle })
|
||||
.collect();
|
||||
let n0 = sh.flanges.len();
|
||||
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?;
|
||||
let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip, relief)?;
|
||||
if !notes.is_empty() {
|
||||
let notes = notes.join("; ");
|
||||
*warning = Some(match warning.take() {
|
||||
@@ -521,7 +536,7 @@ pub(super) fn sheet_eval(
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let i = sheet_for(st, body, edges.first().copied().map(Vec3::from))?;
|
||||
let l = val(vals, length, Kind::Length)?;
|
||||
let g = match gap {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
@@ -531,7 +546,7 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let picks = sheet_picks(sh, f, edges, warning);
|
||||
let picks = sheet_picks(sh, f, &crate::plain(edges), warning);
|
||||
let n0 = sh.flanges.len();
|
||||
for e in &picks {
|
||||
sh.add_hem(*e, l, g, *flip)?;
|
||||
@@ -543,14 +558,14 @@ pub(super) fn sheet_eval(
|
||||
// The sketch line, where it is now.
|
||||
let (a, b) = match (sketch, curve) {
|
||||
(Some(sk), Some(c)) => {
|
||||
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
|
||||
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?;
|
||||
let ci = ss.sketch.curve_index(c).ok_or_else(|| DocError::Unknown(format!("curve `{c}`")))?;
|
||||
match ss.sketch.segs(ci).as_slice() {
|
||||
[Seg2::Line { a, b }] => (ss.plane.to_world(*a), ss.plane.to_world(*b)),
|
||||
_ => return Err(DocError::Invalid("the fold line must be a sketch line".into())),
|
||||
}
|
||||
}
|
||||
_ => (*a, *b),
|
||||
_ => (a.0, b.0),
|
||||
};
|
||||
let i = sheet_for(st, body, Some((a + b) * 0.5))?;
|
||||
let ang = val(vals, angle, Kind::Angle)?;
|
||||
@@ -564,7 +579,7 @@ pub(super) fn sheet_eval(
|
||||
// A fixed point that touches no material at all (e.g. far above the sheet) still picks
|
||||
// the side it lies on, so a stored fold keeps recomputing; it says so (#371).
|
||||
if let Some(p) = fixed
|
||||
&& !on_sheet_material(st, i, *p)
|
||||
&& !on_sheet_material(st, i, p.0)
|
||||
{
|
||||
*warning = Some(format!(
|
||||
"{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays",
|
||||
@@ -579,7 +594,7 @@ pub(super) fn sheet_eval(
|
||||
return Err(DocError::Invalid("refold the sheet before folding it".into()));
|
||||
}
|
||||
let n0 = sh.flanges.len();
|
||||
sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?;
|
||||
sh.add_fold(a, b, ang, rr, position, *flip, fixed.map(Vec3::from), warning)?;
|
||||
tag_flanges(sh, n0, f.id);
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
@@ -604,7 +619,7 @@ pub(super) fn sheet_eval(
|
||||
let mut picks = Vec::with_capacity(bends.len());
|
||||
for (i, p) in bends.iter().enumerate() {
|
||||
let name = f.edge_names.get(i).filter(|n| !n.is_empty());
|
||||
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) {
|
||||
picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, p.0))) {
|
||||
Some((n, Ok(hit))) => {
|
||||
if let Some(note) = hit.note(n) {
|
||||
append_warning(warning, note);
|
||||
@@ -613,21 +628,24 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
Some((n, Err(_))) => {
|
||||
append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point"));
|
||||
*p
|
||||
p.0
|
||||
}
|
||||
None => *p,
|
||||
None => p.0,
|
||||
});
|
||||
}
|
||||
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, face.0, &picks)?;
|
||||
for note in notes {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
tag_flanges(&mut sh, 0, f.id);
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule, .. } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let n = planar_normal_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
// Thickness: the body's extent along the face normal.
|
||||
let extent = |mesh: &solidcraft_geom::Mesh| {
|
||||
mesh.positions.iter().map(|p| (*p - *face).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d)))
|
||||
mesh.positions.iter().map(|p| (*p - face.0).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d)))
|
||||
};
|
||||
// The tolerance (the mesh's chords, and how far off the face's plane a point of it may
|
||||
// be) is at most a tenth of the thickness, not only a thousandth of the size. On a long
|
||||
@@ -645,13 +663,13 @@ pub(super) fn sheet_eval(
|
||||
// The face outline: boundary edges of the triangles on that face's plane, where it runs
|
||||
// along a round wall an arc of the wall's circle (not chords: the plate check below
|
||||
// compares its area with the exact volume).
|
||||
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
let plane = Plane::from_normal(face.0 + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
// Recesses in one face (an engraved label) become pockets; the outline is the other face's.
|
||||
let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?;
|
||||
let recesses = plate_recesses(&mb.body, &mesh, &plane, face.0, n, (lo, hi), tol)?;
|
||||
let mut walls = std::collections::HashMap::new();
|
||||
let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f));
|
||||
let mut loops: Vec<Loop2> =
|
||||
face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
|
||||
face_outline(&mesh, &plane, face.0, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect();
|
||||
loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw();
|
||||
@@ -761,14 +779,24 @@ pub(super) fn sheet_cut(
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let Some(ss) = st.sketch(sketch.0).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some())
|
||||
// The outlines of the sketch's text: the sheet remembers where its letters went.
|
||||
let text: Vec<Seg2> = ss
|
||||
.sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
|
||||
.flat_map(|l| ss.sketch.link_curves(&l.id))
|
||||
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets (else its participants, else its component's
|
||||
@@ -800,6 +828,7 @@ pub(super) fn sheet_primitive_cut(
|
||||
prism: PrimitivePrism,
|
||||
operation: Operation,
|
||||
drill: bool,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
|
||||
return Ok(Vec::new());
|
||||
@@ -824,7 +853,7 @@ pub(super) fn sheet_primitive_cut(
|
||||
let Some(normal) = normal else { return Ok(Vec::new()) };
|
||||
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
|
||||
let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?;
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false)
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false, &[], warning)
|
||||
}
|
||||
|
||||
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
|
||||
@@ -911,6 +940,12 @@ impl PrimitivePrism {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
|
||||
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
|
||||
const MAX_TEXT_CURVES: usize = 20_000;
|
||||
const MAX_LABELS: usize = 1000;
|
||||
|
||||
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_prism_cut(
|
||||
f: &Feature,
|
||||
@@ -922,6 +957,8 @@ fn sheet_prism_cut(
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
footprints_bound: bool,
|
||||
text: &[Seg2],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let n = plane.normal();
|
||||
let mut work = sheet::FlatWork::new();
|
||||
@@ -960,7 +997,14 @@ fn sheet_prism_cut(
|
||||
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
|
||||
.then_some(c.interval)
|
||||
});
|
||||
// The regions of the cut that are letters, with their counters.
|
||||
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
|
||||
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut plans = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !sheet_eligible(f, st, targets, original) {
|
||||
continue;
|
||||
@@ -993,6 +1037,7 @@ fn sheet_prism_cut(
|
||||
sh.material_panels(Some(plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
let mut labels = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
|
||||
@@ -1032,6 +1077,7 @@ fn sheet_prism_cut(
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
// Sub-resolution layers must not be silently erased by endpoint snapping.
|
||||
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
|
||||
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
|
||||
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
|
||||
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
@@ -1096,19 +1142,34 @@ fn sheet_prism_cut(
|
||||
}
|
||||
}
|
||||
}
|
||||
if modeled {
|
||||
for flat in labels {
|
||||
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
|
||||
sh.labels.push(flat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
} else if sh.flat_material()?.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
let material = sh.flat_material()?;
|
||||
if material.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
// Every piece the cut leaves stays; the ones it parts from the base are named.
|
||||
notes.extend(parted_note(&sh, &material, original));
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
|
||||
if !notes.is_empty() {
|
||||
append_warning(warning, notes.join("; "));
|
||||
}
|
||||
for (name, result) in plans {
|
||||
match result {
|
||||
Some((sh, body)) => {
|
||||
@@ -1129,6 +1190,35 @@ fn sheet_prism_cut(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
/// A cut's note on the pieces it parted from the base of `sh` (see
|
||||
/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut,
|
||||
/// `before` the sheet before it): how many, their area and where they lie in the flat pattern.
|
||||
/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the
|
||||
/// pieces can't be worked out: the note is no reason for a cut to fail.
|
||||
fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option<String> {
|
||||
let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?;
|
||||
let area: f64 = pieces.iter().map(Region2::area).sum();
|
||||
let at: Vec<String> = pieces
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|r| {
|
||||
let c = r.interior_point();
|
||||
format!("({:.1}, {:.1})", c.x, c.y)
|
||||
})
|
||||
.collect();
|
||||
let at = at.join(", ");
|
||||
let body = &sh.body;
|
||||
Some(match pieces.len() {
|
||||
1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"),
|
||||
n => {
|
||||
let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() };
|
||||
format!(
|
||||
"{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
@@ -1390,7 +1480,7 @@ fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (l
|
||||
}
|
||||
|
||||
/// The body a placed plastic feature joins: by name, else the one under the point.
|
||||
fn body_under(st: &ModelState, body: &Option<String>, p: Vec3) -> Result<String> {
|
||||
fn body_under(st: &ModelState, body: &Option<crate::BodyName>, p: Vec3) -> Result<String> {
|
||||
let i = body_at(st, body, &[p])?;
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?;
|
||||
let tolerance = (b.body.size() * 1e-5).max(1e-4);
|
||||
@@ -1452,7 +1542,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
rib_offset,
|
||||
body,
|
||||
} => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let (r0, h) = (len(diameter)? / 2.0, len(height)?);
|
||||
if !(r0 > 0.0 && h > 0.0) {
|
||||
return Err(DocError::Invalid("the boss diameter and height must be positive".into()));
|
||||
@@ -1470,9 +1560,9 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
return Err(DocError::Invalid("the draft closes the boss before its top".into()));
|
||||
}
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
let (x, _, z) = local_frame(*direction, None)?;
|
||||
let (x, _, z) = local_frame(direction.0, None)?;
|
||||
// The post: a (tapered) cylinder from a little inside the face.
|
||||
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
|
||||
let base = Plane::new(position.0 - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
|
||||
let r_in = r0 + delta * dr.tan();
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
|
||||
let post = if dr.abs() > 1e-12 {
|
||||
@@ -1505,7 +1595,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
for k in 0..n as usize {
|
||||
let a = std::f64::consts::TAU * k as f64 / n;
|
||||
let (u, v) = (x * a.cos() + z.cross(x) * a.sin(), z.cross(x) * a.cos() - x * a.sin());
|
||||
let rp = Plane::new(*position, u, v).ok_or_else(|| DocError::Invalid("rib frame".into()))?;
|
||||
let rp = Plane::new(position.0, u, v).ok_or_else(|| DocError::Invalid("rib frame".into()))?;
|
||||
let rect = Region2 {
|
||||
outer: Loop2::polygon(&[
|
||||
Vec2::new(r0 * 0.5, -t / 2.0),
|
||||
@@ -1524,7 +1614,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
|
||||
let mut warn = None;
|
||||
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
|
||||
let edges = resolve_edges(&mb.body, &[position.0 + x * r0], &[], &mut warn)?;
|
||||
let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
@@ -1539,7 +1629,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
if !(rh > 0.0 && rh < r_top && depth > 0.0) {
|
||||
return Err(DocError::Invalid("the boss hole must fit inside the boss".into()));
|
||||
}
|
||||
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
|
||||
let hp = Plane::new(position.0, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
|
||||
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
@@ -1547,9 +1637,9 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Lip { face, width, height, groove, gap, outside, body } => {
|
||||
let target = body_under(st, body, *face)?;
|
||||
let target = body_under(st, body, face.0)?;
|
||||
let mb = st.body(&target).cloned().ok_or_else(|| DocError::Invalid("body".into()))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
|
||||
let n = planar_normal_at(&mb.body, face.0).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?;
|
||||
let (w, h) = (len(width)?, len(height)?);
|
||||
let g = match gap {
|
||||
Some(e) => len(e)?,
|
||||
@@ -1559,10 +1649,10 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
if !(w > 0.0 && h > 0.0 && g >= 0.0) {
|
||||
return Err(DocError::Invalid("the lip width and height must be positive".into()));
|
||||
}
|
||||
let plane = Plane::from_normal(*face, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
|
||||
let plane = Plane::from_normal(face.0, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mesh = mb.body.tessellate(tol)?;
|
||||
let mut loops = face_loops(&mesh, &plane, *face, n, 0.0, tol);
|
||||
let mut loops = face_loops(&mesh, &plane, face.0, n, 0.0, tol);
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::<Vec<_>>() } else { l.to_vec() };
|
||||
// The inner edge is an opening (the cavity under a rim): not the foot of a boss or a
|
||||
@@ -1596,7 +1686,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
// A lip: the band raised from the rim's own faces (its walls continue the walls
|
||||
// below, which a join can't do: they would coincide).
|
||||
if !*groove
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, face.0, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature);
|
||||
@@ -1626,17 +1716,17 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let (l, t, w, cd, cl) = (len(length)?, len(thickness)?, len(width)?, len(catch_depth)?, len(catch_length)?);
|
||||
if !(l > 0.0 && t > 0.0 && w > 0.0 && cd > 0.0 && cl > 0.0 && cl < l) {
|
||||
return Err(DocError::Invalid("the snap fit's sizes must be positive and the catch shorter than the arm".into()));
|
||||
}
|
||||
let (x, y, z) = local_frame(*direction, Some(*hook))?;
|
||||
let (x, y, z) = local_frame(direction.0, Some(hook.0))?;
|
||||
let delta = (l * 0.05).clamp(1e-3, 0.5);
|
||||
// Side profile (x = toward the catch, y = up the arm), extruded across the width.
|
||||
let pts =
|
||||
[Vec2::new(-t, -delta), Vec2::new(0.0, -delta), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)];
|
||||
let o = *position - y * (w / 2.0);
|
||||
let o = position.0 - y * (w / 2.0);
|
||||
let pl = Plane::new(o, x, z).ok_or_else(|| DocError::Invalid("snap fit frame".into()))?;
|
||||
let mut lp = Loop2::polygon(&pts);
|
||||
if lp.signed_area() < 0.0 {
|
||||
@@ -1649,7 +1739,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
let target = body_under(st, body, position.0)?;
|
||||
let (w, h) = (len(width)?, len(height)?);
|
||||
let l = length.as_deref().map(len).transpose()?;
|
||||
let dr = match draft {
|
||||
@@ -1672,7 +1762,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
return Err(DocError::Invalid("the draft closes the rest before its top".into()));
|
||||
}
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
let (x, y, z) = local_frame(*direction, *along)?;
|
||||
let (x, y, z) = local_frame(direction.0, along.map(Vec3::from))?;
|
||||
// Sized at the face: the tool starts a little inside, grown by the draft over that.
|
||||
let e = delta * dr.tan();
|
||||
let shape = |grow: f64| match l {
|
||||
@@ -1689,7 +1779,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
None => Vec::new(),
|
||||
};
|
||||
let region = Region2 { outer: shape(e), holes };
|
||||
let base = Plane::new(*position - z * delta, x, y).ok_or_else(|| DocError::Invalid("rest frame".into()))?;
|
||||
let base = Plane::new(position.0 - z * delta, x, y).ok_or_else(|| DocError::Invalid("rest frame".into()))?;
|
||||
let tool = if dr.abs() > 1e-12 {
|
||||
vec![kernel::extrude_tapered(&base, ®ion, h + delta, 1.0, -dr)?]
|
||||
} else {
|
||||
|
||||
+210
-32
@@ -6,21 +6,27 @@ use super::*;
|
||||
use crate::{RuledCorners, RuledMode};
|
||||
use solidcraft_geom::Seg2;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Patch { sketch, profiles, body, edges } => {
|
||||
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
|
||||
let mut made = Vec::new();
|
||||
if let Some(sk) = sketch {
|
||||
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
let ss = st.sketch(sk.0).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||||
for r in ®ions {
|
||||
made.push(kernel::patch_region(&ss.plane, r)?);
|
||||
}
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
let i = body_at(st, body, edges)?;
|
||||
let i = body_at(st, body, &crate::plain(edges))?;
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
|
||||
made.push(kernel::patch_edges(&b.body, edges)?);
|
||||
let spec = kernel::PatchSpec {
|
||||
continuity: continuity.all,
|
||||
edges: continuity.edges.iter().map(|e| (e.edge.0, e.g)).collect(),
|
||||
points: crate::plain(points),
|
||||
crown: *crown,
|
||||
};
|
||||
made.push(kernel::patch_edges_with(&b.body, &crate::plain(edges), &spec)?);
|
||||
}
|
||||
if made.is_empty() {
|
||||
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
|
||||
@@ -59,7 +65,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
if tools.is_empty() {
|
||||
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
|
||||
}
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
apply_op(st, f, tools, *operation, &crate::plain(targets))
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
@@ -67,11 +73,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let trimmed = match tool {
|
||||
Some(t) => {
|
||||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||||
let face = crate::appearance::face_index_at(tb, t.point)
|
||||
let face = crate::appearance::face_index_at(tb, t.point.0)
|
||||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
|
||||
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)?
|
||||
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, keep.0)?
|
||||
}
|
||||
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), *keep)?,
|
||||
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?), keep.0)?,
|
||||
};
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
|
||||
@@ -88,18 +94,20 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
};
|
||||
let at = |limit| kernel::ExtendOptions { mode, limit, neighbours: *neighbours };
|
||||
let out = match up_to {
|
||||
None => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?,
|
||||
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::Bounds))?,
|
||||
None => {
|
||||
kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::Distance(val(vals, distance, Kind::Length)?)))?
|
||||
}
|
||||
Some(crate::ExtendTo::Bounds) => kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::Bounds))?,
|
||||
Some(crate::ExtendTo::Plane(plane)) => {
|
||||
let tool = kernel::SplitTool::Plane(doc.resolve_plane_in(vals, plane, 0, Some(st))?);
|
||||
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
}
|
||||
Some(crate::ExtendTo::Face(t)) => {
|
||||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||||
let face = crate::appearance::face_index_at(tb, t.point)
|
||||
let face = crate::appearance::face_index_at(tb, t.point.0)
|
||||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the target point", t.body)))?;
|
||||
let tool = kernel::SplitTool::Face { body: &tb.body, face };
|
||||
kernel::extend_surface(&mb.body, edges, &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
kernel::extend_surface(&mb.body, &crate::plain(edges), &at(kernel::ExtendLimit::UpTo(&tool)))?
|
||||
}
|
||||
};
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
@@ -107,10 +115,112 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceBlend { from, to, continuity, bulge, align, stitch } => {
|
||||
let a = st.body(&from.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", from.body)))?.body.clone();
|
||||
let b = st.body(&to.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", to.body)))?.body.clone();
|
||||
if from.edges.is_empty() || to.edges.is_empty() {
|
||||
return Err(DocError::Invalid("a blend needs a chain of edges at each end".into()));
|
||||
}
|
||||
let (made, gap) = kernel::blend_between(
|
||||
&a,
|
||||
&crate::plain(&from.edges),
|
||||
&b,
|
||||
&crate::plain(&to.edges),
|
||||
*continuity,
|
||||
*bulge,
|
||||
align.map(|[p, q]| [p.0, q.0]),
|
||||
BLEND_STATIONS,
|
||||
*stitch,
|
||||
)?;
|
||||
if gap > 1e-9 {
|
||||
*warning = Some(format!("the blend's boundary lies up to {gap:.4} mm from the edges it was built on"));
|
||||
}
|
||||
let name = new_name(st, f, 0);
|
||||
// A stitched blend takes the place of both bodies it joins; otherwise it is a new
|
||||
// surface body of its own.
|
||||
if *stitch {
|
||||
let gone = [from.body.0.clone(), to.body.0.clone()];
|
||||
st.bodies.retain(|x| !gone.contains(&x.name));
|
||||
}
|
||||
st.bodies.push(ModelBody::new(name, made, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceBoundary { sketch, curves, orders, crown, samples } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let boundaries = boundary_frames(&ss, curves, orders, *samples)?;
|
||||
let body = kernel::boundary_patch(&boundaries, *crown, *samples)?;
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::new(name, body, f.id));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceMatch { body, faces, target, edges, order } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let tb = st.body(target).ok_or_else(|| DocError::Unknown(format!("body `{target}`")))?.body.clone();
|
||||
if edges.is_empty() {
|
||||
return Err(DocError::Invalid("a match needs a chain of the target's edges".into()));
|
||||
}
|
||||
// Picked by a point on the face; without picks, the body's first face.
|
||||
let picked: Vec<solidcraft_geom::Vec3> = if faces.is_empty() {
|
||||
let faces = mb.body.faces(mb.body.size() * 1e-2)?;
|
||||
vec![faces.first().ok_or_else(|| DocError::Invalid(format!("`{body}` has no face to match")))?.centroid]
|
||||
} else {
|
||||
crate::plain(faces)
|
||||
};
|
||||
let made = kernel::match_surface(&mb.body, &picked, &tb, &crate::plain(edges), *order)?;
|
||||
// A match rebuilds the face in place: the body keeps its name, and whatever meets it
|
||||
// downstream still finds it.
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, made, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceFair { body, faces, strength, iterations, keep_edges, tolerance } => {
|
||||
let tol = val(vals, tolerance, Kind::Length)?;
|
||||
let opts = kernel::FairOptions::new(*strength, *iterations as usize, *keep_edges, tol)?;
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
// Picked by a point on the face; without picks, every face of the body.
|
||||
let mut picked: Vec<usize> = Vec::new();
|
||||
if faces.is_empty() {
|
||||
picked.extend(0..mb.body.face_count());
|
||||
} else {
|
||||
for p in faces {
|
||||
let fi =
|
||||
crate::appearance::face_index_at(&mb, p.0).ok_or_else(|| DocError::Invalid(format!("Fair: no face of {body} at {p:?}")))?;
|
||||
if !picked.contains(&fi) {
|
||||
picked.push(fi);
|
||||
}
|
||||
}
|
||||
}
|
||||
if picked.is_empty() {
|
||||
return Err(DocError::Invalid("the body has no faces to fair".into()));
|
||||
}
|
||||
// The face keeps every edge it had: only the surface between them is faired. The
|
||||
// energy before and after goes into the feature's warning, so the reply can say how
|
||||
// much the face moved.
|
||||
let (faired, report) = kernel::fair_faces(&mb.body, &picked, &opts)?;
|
||||
*warning = Some(format!(
|
||||
"faired {} face(s): energy {:.6} → {:.6} in {} step(s), control points moved up to {:.4} mm",
|
||||
picked.len(),
|
||||
report.energy_before,
|
||||
report.energy_after,
|
||||
report.steps,
|
||||
report.movement
|
||||
));
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, faired, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { sketch, profiles, curves, extent } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
if extent.through_all || extent.taper.is_some() || extent.to.is_some() || extent.from.is_some() {
|
||||
@@ -127,7 +237,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
FeatureKind::SurfaceRevolve { sketch, profiles, curves, axis, angle, angle2, symmetric } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
@@ -148,7 +258,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
FeatureKind::Unstitch { body, faces } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let (pieces, rest) = kernel::unstitch(&mb.body, faces)?;
|
||||
let (pieces, rest) = kernel::unstitch(&mb.body, &crate::plain(faces))?;
|
||||
let source = mb.traced();
|
||||
// The faces left keep the body's name; each face taken out is a new surface body.
|
||||
match rest {
|
||||
@@ -170,16 +280,17 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
FeatureKind::ReverseNormal { body, faces } => {
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let turned = mb.traced().then(kernel::reverse_faces(&mb.body, faces)?);
|
||||
let turned = mb.traced().then(kernel::reverse_faces(&mb.body, &crate::plain(faces))?);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::traced_new(mb.name, turned, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
|
||||
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
// As Sweep: a 3D path sketch is followed in the feature's frame, the curves with it.
|
||||
let profile;
|
||||
let ss = if !ps.sketch.wires.is_empty() {
|
||||
@@ -195,8 +306,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
|
||||
let body = match path_points(ps, path, start)? {
|
||||
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
|
||||
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
|
||||
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
|
||||
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
|
||||
};
|
||||
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
|
||||
push_sheet(st, f, traced);
|
||||
@@ -208,7 +319,7 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
for sec in sections {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(sec.sketch)
|
||||
st.sketch(sec.sketch.0)
|
||||
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?,
|
||||
f.component,
|
||||
)?;
|
||||
@@ -238,9 +349,9 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
Some(t) => val(vals, t, Kind::Length)?,
|
||||
None => 0.01,
|
||||
};
|
||||
let i = body_at(st, body, faces)?;
|
||||
let i = body_at(st, body, &crate::plain(faces))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let picks = if *chain { smooth_chain(&mb, faces) } else { faces.clone() };
|
||||
let picks = if *chain { smooth_chain(&mb, &crate::plain(faces)) } else { crate::plain(faces) };
|
||||
let mut picked: Vec<usize> = Vec::new();
|
||||
for p in &picks {
|
||||
let fi =
|
||||
@@ -261,10 +372,10 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
RuledMode::Tangent => kernel::RuledMode::Tangent,
|
||||
RuledMode::Angle => {
|
||||
let a = angle.as_ref().ok_or_else(|| DocError::Invalid("a ruled surface at an angle needs `angle`".into()))?;
|
||||
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: *direction }
|
||||
kernel::RuledMode::Angle { angle: val(vals, a, Kind::Angle)?, reference: direction.map(Vec3::from) }
|
||||
}
|
||||
RuledMode::Direction => kernel::RuledMode::Direction(
|
||||
direction.ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
|
||||
direction.map(Vec3::from).ok_or_else(|| DocError::Invalid("a ruled surface along a direction needs `direction`".into()))?,
|
||||
),
|
||||
};
|
||||
let corners = match corners {
|
||||
@@ -272,8 +383,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
RuledCorners::Round => kernel::RuledCorners::Round,
|
||||
};
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: *face };
|
||||
let made = Traced::step(kernel::ruled_surface(&mb.body, edges, &r)?, &[&mb.traced()]);
|
||||
let r = kernel::Ruled { distance: d, mode, flip: *flip, corners, face: face.map(Vec3::from) };
|
||||
let made = Traced::step(kernel::ruled_surface(&mb.body, &crate::plain(edges), &r)?, &[&mb.traced()]);
|
||||
let name = new_name(st, f, 0);
|
||||
st.bodies.push(ModelBody::traced_new(name, made, f.id));
|
||||
Ok(())
|
||||
@@ -281,11 +392,11 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
FeatureKind::SurfaceUntrim { face, holes, outer, in_place } => {
|
||||
let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", face.body)))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let fi = crate::appearance::face_index_at(&mb, face.point)
|
||||
let fi = crate::appearance::face_index_at(&mb, face.point.0)
|
||||
.ok_or_else(|| DocError::Invalid(format!("Untrim: no face of {} at {:?}", mb.name, face.point)))?;
|
||||
let which = match holes {
|
||||
None => kernel::UntrimHoles::All,
|
||||
Some(points) => kernel::UntrimHoles::At(points),
|
||||
Some(points) => kernel::UntrimHoles::At(&crate::plain(points)),
|
||||
};
|
||||
// A surface body's face is untrimmed where it is when asked; a solid's is copied out.
|
||||
let replace = *in_place && mb.body.is_surface();
|
||||
@@ -310,6 +421,70 @@ fn push_sheet(st: &mut ModelState, f: &Feature, traced: crate::lineage::Traced)
|
||||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||||
}
|
||||
|
||||
/// The boundaries `surface.boundary` builds its patch from: each picked curve's own stations in
|
||||
/// the sketch's frame, every one of them leaving the boundary square into the loop — the direction
|
||||
/// from the curve towards the middle of the loop, taken across its tangent — over the plane the
|
||||
/// curve lies in (so a chain of planar curves makes a flat patch, and a network that is not
|
||||
/// coplanar is followed as the plane through the curve's own tangent). `orders` sets how much of
|
||||
/// that plane each side repeats.
|
||||
fn boundary_frames(
|
||||
ss: &SolvedSketch,
|
||||
curves: &[String],
|
||||
orders: &[solidcraft_kernel::Continuity],
|
||||
samples: usize,
|
||||
) -> Result<Vec<solidcraft_kernel::Boundary>> {
|
||||
use solidcraft_kernel::{Boundary, Frame};
|
||||
// The stations along each curve, evenly in its own arclength, so every side carries the same
|
||||
// number of frames and the patch's net is square.
|
||||
let mut sides: Vec<Vec<solidcraft_geom::Vec3>> = Vec::with_capacity(curves.len());
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("sketch curve `{id}`")))?;
|
||||
let pieces = ss.sketch.exact_pieces(ci);
|
||||
if pieces.is_empty() {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no exact geometry to build on")));
|
||||
}
|
||||
let lengths: Vec<f64> = pieces.iter().map(Seg2::length).collect();
|
||||
let total: f64 = lengths.iter().sum();
|
||||
if !total.is_finite() || total <= 1e-9 {
|
||||
return Err(DocError::Invalid(format!("curve `{id}` has no length")));
|
||||
}
|
||||
let mut pts = Vec::with_capacity(samples);
|
||||
let (mut used, mut at) = (0.0, 0usize);
|
||||
for k in 0..samples {
|
||||
let want = total * k as f64 / (samples.saturating_sub(1)).max(1) as f64;
|
||||
while at + 1 < pieces.len() && used + lengths[at] < want {
|
||||
used += lengths[at];
|
||||
at += 1;
|
||||
}
|
||||
let (Some(piece), Some(len)) = (pieces.get(at), lengths.get(at)) else { return Err(DocError::Invalid("boundary piece".into())) };
|
||||
let t = ((want - used) / len.max(1e-12)).clamp(0.0, 1.0);
|
||||
pts.push(ss.plane.to_world(piece.point_at(t)));
|
||||
}
|
||||
sides.push(pts);
|
||||
}
|
||||
// The middle of the loop, so every side leaves towards the inside of the patch.
|
||||
let count = sides.iter().map(Vec::len).sum::<usize>().max(1) as f64;
|
||||
let centre = sides.iter().flatten().fold(solidcraft_geom::Vec3::ZERO, |a, p| a + *p) * (1.0 / count);
|
||||
let mut out = Vec::with_capacity(sides.len());
|
||||
for (side, pts) in sides.iter().enumerate() {
|
||||
let mut frames = Vec::with_capacity(pts.len());
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
// The tangent from the stations on either side, one-sided at the ends (where the side
|
||||
// only has one neighbour).
|
||||
let before = if i == 0 { *p } else { pts[i - 1] };
|
||||
let after = if i + 1 == pts.len() { *p } else { pts[i + 1] };
|
||||
let tangent = (after - before).normalized().ok_or_else(|| DocError::Invalid("a boundary curve has a stationary station".into()))?;
|
||||
let across = centre - *p;
|
||||
let leave = (across - tangent * across.dot(tangent))
|
||||
.normalized()
|
||||
.ok_or_else(|| DocError::Invalid("a boundary runs through the middle of the loop: no patch can leave it".into()))?;
|
||||
frames.push(Frame { p: *p, leave, bend: Vec3::ZERO, bend_change: Vec3::ZERO });
|
||||
}
|
||||
out.push(Boundary { frames, order: orders.get(side).copied().unwrap_or(solidcraft_kernel::Continuity::G1) });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a surface builder sweeps: the boundary loops of the picked profiles, then the picked
|
||||
/// curves chained end to end.
|
||||
fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
@@ -328,6 +503,9 @@ fn sheet_chains(ss: &SolvedSketch, profiles: Option<&ProfileSel>, curves: &[Stri
|
||||
|
||||
/// Most sketch curves one feature chains.
|
||||
const MAX_CHAIN_CURVES: usize = 1000;
|
||||
/// Stations a blend samples each of its two boundaries at: enough that the boundary it fits is
|
||||
/// within a small part of a thousandth of the chain's length of the edges it was built on.
|
||||
const BLEND_STATIONS: usize = 41;
|
||||
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
|
||||
+147
-1
@@ -15,6 +15,94 @@ const MAX_LEN: usize = 4096;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
const MAX_ARGS: usize = 64;
|
||||
|
||||
/// An expression a feature holds, as the user wrote it (`2 * width + 5 mm`): kept as text and
|
||||
/// evaluated with the design's parameters. It is saved as a plain string, and reads as one
|
||||
/// (`&expr` is a `&str`); it changes only as a whole (`*expr = new.into()`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Expr(String);
|
||||
|
||||
impl Expr {
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Expr(s.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Expr {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Expr {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Expr {
|
||||
fn from(s: String) -> Self {
|
||||
Expr(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Expr {
|
||||
fn from(s: &str) -> Self {
|
||||
Expr(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for Expr {
|
||||
fn from(s: &String) -> Self {
|
||||
Expr(s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Expr> for String {
|
||||
fn from(e: Expr) -> Self {
|
||||
e.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Expr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for Expr {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for Expr {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<Expr> for String {
|
||||
fn eq(&self, other: &Expr) -> bool {
|
||||
*self == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<String> for Expr {
|
||||
fn eq(&self, other: &String) -> bool {
|
||||
&self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
/// What an expression must evaluate to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -237,6 +325,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
let at = |i: usize| cs.get(i).map(|x| x.0).unwrap_or(s.len());
|
||||
let mut i = 0;
|
||||
let mut out = Vec::new();
|
||||
// Parentheses open at this point: a comma outside them can only be a decimal comma.
|
||||
let mut depth = 0usize;
|
||||
while let Some(&(_, c)) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
@@ -245,6 +335,17 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
|
||||
i += 1;
|
||||
}
|
||||
// A decimal comma (`2,5 mm`, as typed with a numeric keypad in many locales, #1347):
|
||||
// a comma between digits, in a number with no point, outside any parentheses (inside
|
||||
// them a comma separates a function's arguments: `max(1,2)`).
|
||||
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
|
||||
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
|
||||
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
|
||||
i += 1;
|
||||
while digit(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
// Exponent: 1e-3 (but not `2em`).
|
||||
if cs.get(i).is_some_and(|(_, c)| *c == 'e' || *c == 'E') {
|
||||
let sign = cs.get(i + 1).is_some_and(|(_, c)| *c == '-' || *c == '+');
|
||||
@@ -257,7 +358,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
}
|
||||
}
|
||||
let t = s.get(at(st)..at(i)).unwrap_or_default();
|
||||
let v: f64 = t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
|
||||
let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
|
||||
out.push((Tok::Num(v), at(st), at(i)));
|
||||
} else if c.is_alphabetic() || c == '_' || c == 'µ' {
|
||||
let st = i;
|
||||
@@ -268,6 +369,13 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
} else if c == '°' {
|
||||
out.push((Tok::Id("deg".into()), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == '"' || c == '″' {
|
||||
// Inch and foot marks (`1"`, `2'`, #1502), straight or as primes.
|
||||
out.push((Tok::Id("in".into()), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == '\'' || c == '′' {
|
||||
out.push((Tok::Id("ft".into()), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if c == '×' || c == '·' {
|
||||
out.push((Tok::Op('*'), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
@@ -282,6 +390,11 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
out.push((Tok::Op(','), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else if "+-*/^(),%".contains(c) {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
out.push((Tok::Op(c), at(i), at(i + 1)));
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -819,6 +932,39 @@ mod tests {
|
||||
ParamDef { name: n.into(), expr: e.into(), unit: u.into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decimal_comma_outside_parentheses() {
|
||||
// #1347: a comma between digits is a decimal comma where it cannot separate arguments.
|
||||
assert_eq!(ev("2,5", Kind::Unitless).unwrap(), 2.5);
|
||||
assert_eq!(ev("2,5 mm", Kind::Length).unwrap(), 2.5);
|
||||
assert_eq!(ev("12,75mm + 0,25", Kind::Length).unwrap(), 13.0);
|
||||
assert_eq!(ev("width / 2,5", Kind::Length).unwrap(), 16.0);
|
||||
assert!((ev("1,5e3 mm", Kind::Length).unwrap() - 1500.0).abs() < 1e-9);
|
||||
assert!((ev("22,5 deg", Kind::Angle).unwrap() - 22.5f64.to_radians()).abs() < 1e-12);
|
||||
// Inside parentheses a comma still separates a function's arguments.
|
||||
assert_eq!(ev("max(1,2)", Kind::Unitless).unwrap(), 2.0);
|
||||
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
|
||||
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
|
||||
// Not between two digits, or a second comma: still an error.
|
||||
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
|
||||
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inch_and_foot_marks() {
|
||||
// #1502: `1"` in a millimetre design is an inch, `2'` two feet, straight or as primes.
|
||||
assert_eq!(ev("1\"", Kind::Length).unwrap(), 25.4);
|
||||
assert_eq!(ev("1″", Kind::Length).unwrap(), 25.4);
|
||||
assert_eq!(ev("2'", Kind::Length).unwrap(), 609.6);
|
||||
assert_eq!(ev("2′", Kind::Length).unwrap(), 609.6);
|
||||
assert_eq!(ev("1/2\"", Kind::Length).unwrap(), 12.7);
|
||||
assert_eq!(ev("width + 1\"", Kind::Length).unwrap(), 65.4);
|
||||
assert_eq!(ev("2' + 3\"", Kind::Length).unwrap(), 685.8);
|
||||
// A mark alone is its unit alone, as `in` is.
|
||||
assert_eq!(ev("\"", Kind::Length).ok(), ev("in", Kind::Length).ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_units() {
|
||||
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
|
||||
|
||||
@@ -69,8 +69,12 @@ impl FaceAnchor {
|
||||
}
|
||||
let inverse = crate::mat_inverse(&self.author_frame(doc, author)?)
|
||||
.ok_or_else(|| DocError::Invalid("attached sketch author frame is invalid".into()))?;
|
||||
let source =
|
||||
EmbossFace { body: self.target.body.clone(), at: self.target.point, path: self.path.clone(), author_path: self.author_path.clone() };
|
||||
let source = EmbossFace {
|
||||
body: self.target.body.clone().into(),
|
||||
at: crate::FacePick(self.target.point),
|
||||
path: self.path.clone(),
|
||||
author_path: self.author_path.clone(),
|
||||
};
|
||||
Ok(crate::mat_mul(&inverse, &source.body_frame(doc, body)?))
|
||||
}
|
||||
}
|
||||
@@ -78,7 +82,7 @@ impl FaceAnchor {
|
||||
pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
match plane {
|
||||
PlaneRef::Face { anchor, .. } => anchor.as_deref(),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane_face_anchor(base),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => plane_face_anchor(base),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -86,7 +90,7 @@ pub fn plane_face_anchor(plane: &PlaneRef) -> Option<&FaceAnchor> {
|
||||
fn has_face_reference(plane: &PlaneRef) -> bool {
|
||||
match plane {
|
||||
PlaneRef::Face { .. } => true,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => has_face_reference(base),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => has_face_reference(base),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -106,7 +110,9 @@ impl Document {
|
||||
}
|
||||
match plane {
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => *plane = PlaneRef::Custom { plane: doc.resolve_face_anchor(anchor, author, state)? },
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => replace(doc, base, author, state, depth + 1)?,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => {
|
||||
replace(doc, base, author, state, depth + 1)?
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -447,7 +453,7 @@ mod tests {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: source,
|
||||
sketch: crate::SketchId(source),
|
||||
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
@@ -558,7 +564,7 @@ mod tests {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
sketch: crate::SketchId(sk),
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::FeatureKind;
|
||||
use crate::walk::Walk;
|
||||
use crate::{BodyName, FeatureKind, FeatureName, SketchId};
|
||||
|
||||
/// A reference a feature holds: `S` is `&u64` or `&mut u64` (a sketch id), `N` is `&String` or
|
||||
/// `&mut String` (a feature or body name).
|
||||
pub(crate) enum Ref<S, N> {
|
||||
/// A reference a feature holds: `S` is `&SketchId` or `&mut SketchId`, and so on.
|
||||
pub(crate) enum Ref<S, F, B> {
|
||||
Sketch(S),
|
||||
Feature(N),
|
||||
Body(N),
|
||||
Feature(F),
|
||||
Body(B),
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
@@ -23,7 +22,7 @@ impl FeatureKind {
|
||||
let mut v = Vec::new();
|
||||
self.walk_refs(&mut |r| {
|
||||
if let Ref::Sketch(id) = r {
|
||||
v.push(*id);
|
||||
v.push(id.0);
|
||||
}
|
||||
});
|
||||
v
|
||||
@@ -34,13 +33,13 @@ impl FeatureKind {
|
||||
pub fn remap_refs(&mut self, sketches: &BTreeMap<u64, u64>, names: &BTreeMap<String, String>) {
|
||||
self.walk_refs_mut(&mut |r| match r {
|
||||
Ref::Sketch(id) => {
|
||||
if let Some(n) = sketches.get(id) {
|
||||
*id = *n;
|
||||
if let Some(n) = sketches.get(&id.0) {
|
||||
*id = SketchId(*n);
|
||||
}
|
||||
}
|
||||
Ref::Feature(name) => {
|
||||
if let Some(n) = names.get(name) {
|
||||
*name = n.clone();
|
||||
if let Some(n) = names.get(name.as_ref()) {
|
||||
*name = FeatureName(n.clone());
|
||||
}
|
||||
}
|
||||
Ref::Body(_) => {}
|
||||
@@ -51,9 +50,9 @@ impl FeatureKind {
|
||||
pub fn remap_bodies(&mut self, bodies: &BTreeMap<String, String>) {
|
||||
self.walk_refs_mut(&mut |r| {
|
||||
if let Ref::Body(name) = r
|
||||
&& let Some(n) = bodies.get(name)
|
||||
&& let Some(n) = bodies.get(name.as_ref())
|
||||
{
|
||||
*name = n.clone();
|
||||
*name = BodyName(n.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -73,9 +73,9 @@ fn ref_record() -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A variant with a field that refers to a sketch (a field named `*sketch*` holding an id, sketch
|
||||
/// points or loft sections) must report it from `input_sketches`, or deleting the sketch leaves
|
||||
/// the feature broken. New variants are caught by the walk's exhaustive match; this catches a
|
||||
/// A variant with a field that refers to a sketch (a `SketchId`, sketch points or loft
|
||||
/// sections) must report it from `input_sketches`, or deleting the sketch leaves the feature
|
||||
/// broken. New variants are caught by the walk's exhaustive match; this catches a
|
||||
/// sketch field added to an existing variant (its fixture has to set it).
|
||||
/// Per fixture (with every reference and position set): the fields `transform_geometry` changes
|
||||
/// under a turn and a shift (every point, direction and plane it maps), the face points that name
|
||||
@@ -107,7 +107,7 @@ fn geometry_record() -> String {
|
||||
out += &format!(" profiles: {}\n", profiles.join(", "));
|
||||
}
|
||||
if let Some(p) = kind.point_expr() {
|
||||
out += &format!(" placement: {}\n", p.join(", "));
|
||||
out += &format!(" placement: {}\n", p.iter().map(|e| e.as_str()).collect::<Vec<_>>().join(", "));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -151,8 +151,8 @@ fn input_sketches_lists_every_variant_with_a_sketch_field() {
|
||||
variant = t.split(|c: char| !c.is_alphanumeric()).next().unwrap_or("").to_lowercase();
|
||||
continue;
|
||||
}
|
||||
let Some((name, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = (name.contains("sketch") && ty.contains("u64")) || ty.contains("SketchPoints") || ty.contains("LoftSection");
|
||||
let Some((_, ty)) = t.strip_prefix("pub ").unwrap_or(t).split_once(':') else { continue };
|
||||
let refers = ty.contains("SketchId") || ty.contains("SketchPoints") || ty.contains("LoftSection");
|
||||
if refers && !t.starts_with("//") && !with_sketch.contains(&variant) {
|
||||
with_sketch.push(variant.clone());
|
||||
}
|
||||
|
||||
@@ -75,10 +75,16 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
|
||||
("loft", json!({"type": "loft", "sections": []})),
|
||||
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
|
||||
(
|
||||
"sweep_options",
|
||||
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
|
||||
),
|
||||
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
|
||||
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
|
||||
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
|
||||
("split", json!({"type": "split", "body": "B", "plane": plane("split")})),
|
||||
("split_line", json!({"type": "split", "body": "B", "plane": plane("split_line")})),
|
||||
(
|
||||
"construction_axis",
|
||||
json!({"type": "construction_axis", "def": {"type": "two_planes", "a": geo_plane("axis_a"), "b": geo_plane("axis_b")}}),
|
||||
@@ -109,7 +115,7 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("sheet_contour", json!({"type": "sheet_contour", "sketch": 1, "curves": ["l1"], "distance": "contour.distance"})),
|
||||
(
|
||||
"sheet_flange",
|
||||
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius"}),
|
||||
json!({"type": "sheet_flange", "edges": [{"point": o, "angle": "flange.e1_angle", "height": "flange.e1_height"}, {"point": o, "angle": null, "height": "flange.e2_height"}], "height": "flange.height", "angle": "flange.angle", "radius": "flange.radius", "corner_relief_shape": "round", "corner_relief": "flange.relief"}),
|
||||
),
|
||||
("sheet_hem", json!({"type": "sheet_hem", "edges": [o], "length": "hem.length", "gap": "hem.gap"})),
|
||||
(
|
||||
@@ -147,8 +153,19 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
|
||||
),
|
||||
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
|
||||
(
|
||||
"surface_sweep_options",
|
||||
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
|
||||
),
|
||||
("surface_loft", json!({"type": "surface_loft", "sections": []})),
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
|
||||
// The Class-A surface family (surface.blend/boundary/match/fair).
|
||||
("surface_blend", json!({"type": "surface_blend", "from": {"body": "B", "edges": [o]}, "to": {"body": "C", "edges": [o]}, "align": [o, o]})),
|
||||
("surface_boundary", json!({"type": "surface_boundary", "sketch": 1, "curves": ["l1", "l2"]})),
|
||||
("surface_match", json!({"type": "surface_match", "body": "B", "faces": [o], "target": "C", "edges": [o]})),
|
||||
("surface_fair", json!({"type": "surface_fair", "body": "B", "faces": [o], "tolerance": "sfair.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
("reverse_normal", json!({"type": "reverse_normal", "body": "B"})),
|
||||
("surface_untrim", json!({"type": "surface_untrim", "face": {"body": "B", "point": o}, "holes": [o], "outer": true})),
|
||||
@@ -181,7 +198,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
let o = [0, 0, 0];
|
||||
let tool = json!({"body": "B", "point": o});
|
||||
let extra = match name {
|
||||
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
|
||||
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
|
||||
| "snap_fit" | "rest" | "surface_offset" => {
|
||||
@@ -192,6 +209,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
"hole_simple" | "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"points": {"sketch": 3, "ids": ["p"]}}),
|
||||
"loft" => json!({"sections": [{"sketch": 4, "profiles": {"type": "all"}}, {"sketch": 5, "profiles": {"type": "all"}}], "targets": ["B"]}),
|
||||
"split" | "surface_trim" => json!({"tool": tool}),
|
||||
"split_line" => json!({"sketch": 6, "line": "l1"}),
|
||||
"surface_loft" => json!({"sections": [{"sketch": 4}, {"sketch": 5}]}),
|
||||
"split_face" => json!({"body": "B", "tool": tool, "sketch": 6}),
|
||||
"emboss" => json!({"face": {"body": "B", "at": o}, "targets": ["B"]}),
|
||||
|
||||
@@ -136,6 +136,11 @@ thread
|
||||
expressions: thread.length
|
||||
loft
|
||||
sweep
|
||||
sweep_options
|
||||
inputs:
|
||||
Twist = sweep.twist (Angle)
|
||||
Scale = sweep.scale (Unitless)
|
||||
expressions: sweep.twist, sweep.scale
|
||||
shell
|
||||
inputs:
|
||||
Thickness = shell.thickness (Length)
|
||||
@@ -156,6 +161,11 @@ split
|
||||
Angle = split.angle (Angle)
|
||||
Offset = split.offset (Length)
|
||||
expressions: split.angle, split.offset
|
||||
split_line
|
||||
inputs:
|
||||
Angle = split_line.angle (Angle)
|
||||
Offset = split_line.offset (Length)
|
||||
expressions: split_line.angle, split_line.offset
|
||||
construction_axis
|
||||
inputs:
|
||||
Angle = axis_a.angle (Angle)
|
||||
@@ -238,7 +248,8 @@ sheet_flange
|
||||
Edge angle = flange.e1_angle (Angle)
|
||||
Edge height = flange.e1_height (Length)
|
||||
Edge height = flange.e2_height (Length)
|
||||
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height
|
||||
Corner relief = flange.relief (Length)
|
||||
expressions: flange.height, flange.angle, flange.radius, flange.e1_angle, flange.e1_height, flange.e2_height, flange.relief
|
||||
sheet_hem
|
||||
inputs:
|
||||
Length = hem.length (Length)
|
||||
@@ -317,12 +328,25 @@ surface_revolve
|
||||
Angle 2 = srev.angle2 (Angle)
|
||||
expressions: srev.angle, srev.angle2
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
inputs:
|
||||
Twist = surface_sweep.twist (Angle)
|
||||
Scale = surface_sweep.scale (Unitless)
|
||||
expressions: surface_sweep.twist, surface_sweep.scale
|
||||
surface_loft
|
||||
surface_blend
|
||||
surface_offset
|
||||
inputs:
|
||||
Distance = soff.distance (Length)
|
||||
Tolerance = soff.tolerance (Length)
|
||||
expressions: soff.distance, soff.tolerance
|
||||
surface_blend
|
||||
surface_boundary
|
||||
surface_match
|
||||
surface_fair
|
||||
inputs:
|
||||
Tolerance = sfair.tolerance (Length)
|
||||
expressions: sfair.tolerance
|
||||
unstitch
|
||||
reverse_normal
|
||||
surface_untrim
|
||||
|
||||
@@ -63,6 +63,9 @@ loft
|
||||
profiles: 4 all, 5 all
|
||||
sweep
|
||||
profiles: 1 all
|
||||
sweep_options
|
||||
moves: orientation.direction[1], orientation.direction[2]
|
||||
profiles: 1 all
|
||||
shell
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
@@ -73,6 +76,8 @@ construction_plane
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
split
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2], tool.point[0], tool.point[1], tool.point[2]
|
||||
split_line
|
||||
moves: plane.axis_dir[0], plane.axis_dir[1], plane.axis_origin[0], plane.axis_origin[1], plane.axis_origin[2]
|
||||
construction_axis
|
||||
moves: def.a.plane.axis_dir[0], def.a.plane.axis_dir[1], def.a.plane.axis_origin[0], def.a.plane.axis_origin[1], def.a.plane.axis_origin[2], def.b.plane.axis_dir[0], def.b.plane.axis_dir[1], def.b.plane.axis_origin[0], def.b.plane.axis_origin[1], def.b.plane.axis_origin[2]
|
||||
construction_point_planes
|
||||
@@ -142,10 +147,22 @@ surface_extrude
|
||||
surface_revolve
|
||||
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
surface_loft
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_offset
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_blend
|
||||
moves: align[0][0], align[0][1], align[0][2], align[1][0], align[1][1], align[1][2], from.edges[0][0], from.edges[0][1], from.edges[0][2], to.edges[0][0], to.edges[0][1], to.edges[0][2]
|
||||
surface_boundary
|
||||
surface_match
|
||||
moves: edges[0][0], edges[0][1], edges[0][2], faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
surface_fair
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
unstitch
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
reverse_normal
|
||||
|
||||
@@ -67,6 +67,10 @@ sweep
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
remap_bodies: targets[0]
|
||||
sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
remap_bodies: targets[0]
|
||||
shell
|
||||
remap_bodies: body
|
||||
draft
|
||||
@@ -77,6 +81,10 @@ construction_plane
|
||||
split
|
||||
remap_refs: plane.base.base.name
|
||||
remap_bodies: body, tool.body
|
||||
split_line
|
||||
sketches: [6]
|
||||
remap_refs: plane.base.base.name, sketch
|
||||
remap_bodies: body
|
||||
construction_axis
|
||||
construction_point_planes
|
||||
construction_point_along
|
||||
@@ -159,11 +167,25 @@ surface_revolve
|
||||
surface_sweep
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_loft
|
||||
sketches: [4, 5]
|
||||
remap_refs: sections[0].sketch, sections[1].sketch
|
||||
surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_offset
|
||||
remap_bodies: body
|
||||
surface_blend
|
||||
remap_bodies: from.body
|
||||
surface_boundary
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
surface_match
|
||||
remap_bodies: body
|
||||
surface_fair
|
||||
remap_bodies: body
|
||||
unstitch
|
||||
remap_bodies: body
|
||||
reverse_normal
|
||||
|
||||
+329
-10
@@ -191,13 +191,142 @@
|
||||
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
|
||||
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
|
||||
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
|
||||
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
|
||||
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
|
||||
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
|
||||
//! format 21, so no released build sweeps a twisted or scaled profile straight.
|
||||
//! Nothing new is saved for converted sheets' corners and bend ends, but a design converts as
|
||||
//! the rule says when it is evaluated: corners between the walls take the rule's corner relief
|
||||
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
|
||||
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
|
||||
//! (0.11.0 refused it).
|
||||
//! - 22: an Import (STEP) feature can list `skip`: indices of the file's bodies it does not
|
||||
//! build, because they are later instances of a part the file places more than once and the
|
||||
//! other occurrences of that part's component show them (#1386: an imported file becomes a
|
||||
//! component, an assembly in it nested components). Version 21 designs need no
|
||||
//! transformation: an Import without it builds every body, as it always did, and designs
|
||||
//! whose imports sit loose in a component are left as they are. The version stamp makes
|
||||
//! 0.12.0 (which reads up to 21) ask for an update instead of building those instances again
|
||||
//! on top of their occurrences.
|
||||
//! Also under 22, a number in an expression can be written with a decimal comma outside
|
||||
//! parentheses (`2,5 mm` is `2.5 mm`, #1347; inside a function's parentheses the comma still
|
||||
//! separates its arguments). Nothing new is saved, but an expression typed that way is saved as
|
||||
//! typed; 0.12.0 asks for an update instead of failing to evaluate it.
|
||||
//! Also under 22, a design can hold a `SurfaceBlend` (Blend Surface: `from`/`to` edge chains of
|
||||
//! two bodies, optional `continuity`, `bulge`, `align`, `stitch`), and a `Patch` can carry a
|
||||
//! `crown` (0 by default, and then not saved: a bump that vanishes on the loop, so the face
|
||||
//! still meets the picked edges). Version 21 designs need no transformation; 0.12.0 asks for an
|
||||
//! update instead of meeting a feature it can't read or dropping a patch's crown.
|
||||
//! Also under 22, a sketch text's link source can say that its place, height or angle are
|
||||
//! expressions (`exprs`: `x`, `y`, `height`, `angle`, each an expression over the design's
|
||||
//! parameters); the source's `at`, `height` and `angle` then hold the numbers they last came
|
||||
//! to. A text without `exprs` is set as it always was; 0.12.0 asks for an update instead of
|
||||
//! dropping the expressions and keeping the text at its last numbers.
|
||||
//!
|
||||
//! Later formats are one file per change, so changes made side by side never edit the same line
|
||||
//! (`src/format/pending/README.md`):
|
||||
//! - `src/format/v<N>/`: the changes format N added, one file each, its `//!` note saying what is
|
||||
//! saved and what older designs get, and a `step` when older designs are rewritten as they open
|
||||
//! (23: motion link couplings, FreeCAD and Rhino imports; 24: an Edge Flange's own corner relief).
|
||||
//! - `src/format/pending/`: changes no release has fixed a number for yet. While there are any,
|
||||
//! this build writes the next number (which no release reads), with the names of these changes
|
||||
//! in `format_pending`, so a later build applies just the steps a file is missing. At release
|
||||
//! time `scripts/fold-format-pending.sh` moves them into the next `v<N>/`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 21;
|
||||
/// The format this build writes: the last folded one, or the next while changes are pending.
|
||||
pub const FORMAT: u32 = FORMATS.current();
|
||||
|
||||
/// The last format whose upgrade step is written out in [`JSON_STEPS`]; later formats are made
|
||||
/// of [`Part`]s (`src/format/`, listed by `build.rs`).
|
||||
const HAND_WRITTEN: u32 = 22;
|
||||
|
||||
/// One change to the file format: a file in `src/format/v<N>/` or `src/format/pending/`.
|
||||
pub(crate) struct Part {
|
||||
/// The file's name, which a file written while the change was pending lists.
|
||||
pub name: &'static str,
|
||||
/// How a design from before the change is rewritten as it opens, if it is.
|
||||
pub step: Option<JsonStep>,
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/format_parts.rs"));
|
||||
|
||||
/// Every format this build knows and how to upgrade a design through them.
|
||||
const FORMATS: Formats<'static> = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: PENDING };
|
||||
|
||||
/// The names of the pending changes this build writes into a design (`format_pending`).
|
||||
pub fn pending_names() -> Vec<String> {
|
||||
FORMATS.pending.iter().map(|p| p.name.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The upgrade path: the hand-written steps up to [`HAND_WRITTEN`], the folded formats after it
|
||||
/// (each a list of changes) and the pending changes, which make the next format.
|
||||
struct Formats<'a> {
|
||||
hand: &'a [JsonStep],
|
||||
folded: &'a [(u32, &'a [Part])],
|
||||
pending: &'a [Part],
|
||||
}
|
||||
|
||||
impl Formats<'_> {
|
||||
const fn current(&self) -> u32 {
|
||||
self.hand.len() as u32 + 1 + self.folded.len() as u32 + if self.pending.is_empty() { 0 } else { 1 }
|
||||
}
|
||||
|
||||
/// The changes format `version` is made of, if it is made of changes.
|
||||
fn parts(&self, version: u32) -> Option<&[Part]> {
|
||||
let after = version.checked_sub(self.hand.len() as u32 + 2)? as usize;
|
||||
match self.folded.get(after) {
|
||||
Some((_, parts)) => Some(parts),
|
||||
None if after == self.folded.len() && !self.pending.is_empty() => Some(self.pending),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring a design of format `version` up to [`Formats::current`]. `applied` is the file's
|
||||
/// `format_pending`: the changes of its format it already has, when it was written while they
|
||||
/// were pending; the others' steps run first. A pending change this build doesn't have is a
|
||||
/// newer build's, and the design is refused.
|
||||
fn upgrade(&self, v: &mut Value, version: u32, applied: Option<&[String]>) -> Result<()> {
|
||||
if version == 0 || version > self.current() {
|
||||
return Err(DocError::Invalid(format!("not a SolidCraft design (format {version})")));
|
||||
}
|
||||
// A list beside a format that isn't made of changes means nothing (a tool or a test set
|
||||
// an older number on a newer file): it is ignored, like any field an upgrade drops.
|
||||
if let (Some(applied), Some(parts)) = (applied, self.parts(version)) {
|
||||
let pending = version == self.current() && !self.pending.is_empty();
|
||||
if let Some(unknown) = applied.iter().find(|name| pending && !parts.iter().any(|p| p.name == name.as_str())) {
|
||||
return Err(DocError::Invalid(format!(
|
||||
"this design was saved by a newer SolidCraft (file format {version} with `{unknown}`, which this version doesn't know): update SolidCraft to open it"
|
||||
)));
|
||||
}
|
||||
for part in parts.iter().filter(|p| !applied.iter().any(|name| name == p.name)) {
|
||||
part.step.map_or(Ok(()), |step| step(v))?;
|
||||
}
|
||||
}
|
||||
for from in version..self.current() {
|
||||
match self.hand.get(from as usize - 1) {
|
||||
Some(step) => step(v)?,
|
||||
None => {
|
||||
for part in self.parts(from + 1).unwrap_or_default() {
|
||||
part.step.map_or(Ok(()), |step| step(v))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a file's `format_pending` (the pending changes it has) out of its JSON.
|
||||
fn take_pending(v: &mut Value) -> Result<Option<Vec<String>>> {
|
||||
let Some(list) = v.as_object_mut().and_then(|o| o.remove("format_pending")) else { return Ok(None) };
|
||||
let invalid = || DocError::Invalid("damaged design: `format_pending` is not a short list of names".into());
|
||||
let list = list.as_array().filter(|l| l.len() <= 1000).ok_or_else(invalid)?;
|
||||
list.iter().map(|n| n.as_str().filter(|n| n.len() <= 128).map(str::to_string).ok_or_else(invalid)).collect::<Result<_>>().map(Some)
|
||||
}
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -212,9 +341,10 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
s.strip_prefix("solidcraft/").or_else(|| s.strip_prefix(LEGACY_FORMAT_PREFIX))?.parse().ok()
|
||||
}
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
/// JSON-level upgrades up to [`HAND_WRITTEN`]: entry `i` turns format `i + 1` into `i + 2`.
|
||||
/// Never add to it: a new format is a file in `src/format/pending/`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 20] = [
|
||||
const JSON_STEPS: [JsonStep; HAND_WRITTEN as usize - 1] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -235,6 +365,7 @@ const JSON_STEPS: [JsonStep; 20] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -425,6 +556,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
Some(Value::String(f)) => version_of(f).ok_or_else(|| DocError::Invalid(format!("not a SolidCraft design (format `{f}`)")))?,
|
||||
Some(_) => return Err(DocError::Invalid("not a SolidCraft design (format is not a string)".into())),
|
||||
};
|
||||
let applied = take_pending(&mut v)?;
|
||||
if version == 0 {
|
||||
return Err(DocError::Invalid("not a SolidCraft design (format 0)".into()));
|
||||
}
|
||||
@@ -444,9 +576,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
crate::format_offsets::validate_raw(&v)?;
|
||||
let legacy_wires = validate_wires(&v, version)?;
|
||||
validate_body_transfers(&v)?;
|
||||
for step in JSON_STEPS.iter().skip(version as usize - 1).take((FORMAT - version) as usize) {
|
||||
step(&mut v)?;
|
||||
}
|
||||
FORMATS.upgrade(&mut v, version, applied.as_deref())?;
|
||||
let mut d: Document = serde_json::from_value(v).map_err(|e| DocError::Invalid(format!("design file: {e}")))?;
|
||||
let mut legacy_by_feature = std::collections::BTreeMap::<usize, Vec<usize>>::new();
|
||||
for (feature, wire) in legacy_wires {
|
||||
@@ -545,6 +675,7 @@ pub fn read(s: &str) -> Result<Document> {
|
||||
let floor = largest.checked_add(1).ok_or_else(|| DocError::Invalid("damaged design: the id space is exhausted".into()))?;
|
||||
d.next_id = d.next_id.max(floor).max(1);
|
||||
d.format = format_string(FORMAT);
|
||||
d.format_pending = pending_names();
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
@@ -967,6 +1098,170 @@ fn normalise(d: &mut Document) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A pending change that records that its step ran, in `trace`.
|
||||
fn traced(name: &'static str) -> Part {
|
||||
fn step_a(v: &mut Value) -> Result<()> {
|
||||
trace(v, "a")
|
||||
}
|
||||
fn step_b(v: &mut Value) -> Result<()> {
|
||||
trace(v, "b")
|
||||
}
|
||||
fn trace(v: &mut Value, s: &str) -> Result<()> {
|
||||
let o = v.as_object_mut().ok_or_else(|| DocError::Invalid("no object".into()))?;
|
||||
o.entry("trace").or_insert_with(|| Value::Array(Vec::new())).as_array_mut().into_iter().for_each(|t| t.push(Value::from(s)));
|
||||
Ok(())
|
||||
}
|
||||
Part { name, step: Some(if name.starts_with('a') { step_a } else { step_b }) }
|
||||
}
|
||||
|
||||
fn trace_of(v: &Value) -> Vec<&str> {
|
||||
v["trace"].as_array().into_iter().flatten().filter_map(Value::as_str).collect()
|
||||
}
|
||||
|
||||
/// Formats after the hand-written ones follow on from them, one folder each, and the build
|
||||
/// writes the next number exactly while changes are pending, so the last release (which
|
||||
/// reads up to 22) refuses what a development build saves.
|
||||
#[test]
|
||||
fn formats_follow_on_and_pending_changes_take_the_next_number() {
|
||||
assert_eq!(JSON_STEPS.len() as u32, HAND_WRITTEN - 1);
|
||||
for (i, (version, parts)) in FOLDED.iter().enumerate() {
|
||||
assert_eq!(*version, HAND_WRITTEN + 1 + i as u32);
|
||||
assert!(!parts.is_empty(), "format {version} has no changes");
|
||||
}
|
||||
let last_folded = HAND_WRITTEN + FOLDED.len() as u32;
|
||||
assert_eq!(FORMAT, last_folded + u32::from(!PENDING.is_empty()));
|
||||
const { assert!(FORMAT >= 24, "designs saved as 23 and 24 by nightlies still open") };
|
||||
assert_eq!(pending_names(), PENDING.iter().map(|p| p.name).collect::<Vec<_>>());
|
||||
assert_eq!(Document::new("A").format_pending, pending_names());
|
||||
}
|
||||
|
||||
/// The build lists every change file there is, in file-name order: a change is a file, so
|
||||
/// two branches that each add one merge cleanly and the merged build has both.
|
||||
#[test]
|
||||
fn every_change_file_is_listed_in_name_order() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/format");
|
||||
let names = |dir: &str| -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(root.join(dir))
|
||||
.map(|d| d.filter_map(|e| e.ok()?.file_name().to_str()?.strip_suffix(".rs").map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
assert_eq!(names("pending"), pending_names());
|
||||
for (version, parts) in FOLDED {
|
||||
assert_eq!(names(&format!("v{version}")), parts.iter().map(|p| p.name).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
|
||||
/// Two changes pending side by side both run, in name order, after every older format's
|
||||
/// steps: an old design upgrades through all of them, and one from the last folded format
|
||||
/// gets just the pending ones.
|
||||
#[test]
|
||||
fn pending_changes_apply_in_order_after_older_formats() {
|
||||
let pending = [traced("a-first"), traced("b-second")];
|
||||
let formats = Formats { hand: &JSON_STEPS, folded: FOLDED, pending: &pending };
|
||||
let next = HAND_WRITTEN + FOLDED.len() as u32 + 1;
|
||||
assert_eq!(formats.current(), next);
|
||||
for version in [1, HAND_WRITTEN, next - 1] {
|
||||
let mut v = serde_json::json!({"features": [], "assembly": {"joints": [{"flip": false}]}});
|
||||
formats.upgrade(&mut v, version, None).unwrap();
|
||||
assert_eq!(trace_of(&v), ["a", "b"], "from format {version}");
|
||||
// The hand-written steps ran too: format 3's joints were flipped on the way up.
|
||||
assert_eq!(v["assembly"]["joints"][0]["flip"], Value::Bool(version < 4), "from format {version}");
|
||||
}
|
||||
// A design of the pending number written before `b-second` existed gets just that step.
|
||||
let mut v = serde_json::json!({});
|
||||
formats.upgrade(&mut v, next, Some(&["a-first".to_string()])).unwrap();
|
||||
assert_eq!(trace_of(&v), ["b"]);
|
||||
// One that has both, or that this build wrote, needs nothing.
|
||||
let mut v = serde_json::json!({});
|
||||
formats.upgrade(&mut v, next, Some(&["a-first".into(), "b-second".into()])).unwrap();
|
||||
assert_eq!(trace_of(&v), Vec::<&str>::new());
|
||||
// A change this build doesn't know is a newer build's: refused, not guessed at.
|
||||
let err = formats.upgrade(&mut serde_json::json!({}), next, Some(&["c-later".into()])).unwrap_err();
|
||||
assert!(err.to_string().contains("newer SolidCraft"), "{err}");
|
||||
// A list on a format that isn't made of changes means nothing.
|
||||
let mut v = serde_json::json!({});
|
||||
formats.upgrade(&mut v, HAND_WRITTEN, Some(&["a-first".into()])).unwrap();
|
||||
assert_eq!(trace_of(&v), ["a", "b"]);
|
||||
}
|
||||
|
||||
/// Folding the pending changes into a number (the release script) keeps every design
|
||||
/// opening the same: a file saved while they were pending gets the steps it missed.
|
||||
#[test]
|
||||
fn folded_changes_upgrade_like_pending_ones() {
|
||||
let parts = [traced("a-first"), traced("b-second")];
|
||||
let mut folded: Vec<(u32, &[Part])> = FOLDED.to_vec();
|
||||
let next = HAND_WRITTEN + folded.len() as u32 + 1;
|
||||
folded.push((next, &parts));
|
||||
let formats = Formats { hand: &JSON_STEPS, folded: &folded, pending: &[] };
|
||||
assert_eq!(formats.current(), next);
|
||||
let mut old = serde_json::json!({});
|
||||
formats.upgrade(&mut old, HAND_WRITTEN, None).unwrap();
|
||||
assert_eq!(trace_of(&old), ["a", "b"]);
|
||||
let mut nightly = serde_json::json!({});
|
||||
formats.upgrade(&mut nightly, next, Some(&["a-first".into()])).unwrap();
|
||||
assert_eq!(trace_of(&nightly), ["b"]);
|
||||
let mut released = serde_json::json!({});
|
||||
formats.upgrade(&mut released, next, None).unwrap();
|
||||
assert_eq!(trace_of(&released), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
/// `format_pending` is read and checked: a damaged list is refused, a list of changes this
|
||||
/// build doesn't have is a newer build's, and saving writes this build's own list.
|
||||
#[test]
|
||||
fn format_pending_in_a_file_is_checked() {
|
||||
let mut file: Value = serde_json::from_str(&Document::new("A").to_json()).unwrap();
|
||||
assert_eq!(file.get("format_pending").is_some(), !PENDING.is_empty());
|
||||
for damaged in [serde_json::json!("x"), serde_json::json!([1]), serde_json::json!(["x".repeat(200)])] {
|
||||
file["format_pending"] = damaged;
|
||||
assert!(read(&file.to_string()).is_err());
|
||||
}
|
||||
file["format_pending"] = serde_json::json!(["not-a-change-anyone-made"]);
|
||||
assert_eq!(read(&file.to_string()).is_err(), !PENDING.is_empty(), "only a pending format's list names changes");
|
||||
file["format_pending"] = Value::from(pending_names());
|
||||
let opened = read(&file.to_string()).unwrap();
|
||||
assert_eq!(opened.format_pending, pending_names());
|
||||
}
|
||||
|
||||
/// 24 added the corner relief of an edge flange: a no-op step carries a 23 design up, a flange
|
||||
/// without a relief of its own saves as before, and one with it is saved under 24 and kept.
|
||||
#[test]
|
||||
fn version_twenty_three_designs_open_and_an_edge_flange_relief_is_saved_in_twenty_four() {
|
||||
let flange = |shape: Option<&str>, size: Option<&str>| crate::FeatureKind::SheetFlange {
|
||||
edges: vec![crate::FlangeEdge::at(solidcraft_geom::Vec3::ZERO)],
|
||||
height: "10 mm".into(),
|
||||
angle: "90 deg".into(),
|
||||
radius: None,
|
||||
position: "inside".into(),
|
||||
flip: false,
|
||||
body: None,
|
||||
corner_relief_shape: shape.map(Into::into),
|
||||
corner_relief: size.map(Into::into),
|
||||
};
|
||||
let saved = |document: &Document| -> Value { serde_json::from_str(&document.to_json()).unwrap() };
|
||||
// A flange that follows the rule saves neither key, and a design of format 23 opens as it was.
|
||||
let mut plain = Document::new("A");
|
||||
plain.add_feature(flange(None, None), None).unwrap();
|
||||
let mut file = saved(&plain);
|
||||
let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap();
|
||||
assert_eq!(feature["type"], "sheet_flange");
|
||||
assert!(feature.get("corner_relief_shape").is_none() && feature.get("corner_relief").is_none(), "{feature}");
|
||||
file["format"] = Value::from("solidcraft/23");
|
||||
let opened = read(&file.to_string()).unwrap();
|
||||
assert_eq!(opened.format, format_string(FORMAT));
|
||||
assert_eq!(opened.features, plain.features);
|
||||
// One that chooses its relief says so in the file, which is stamped 24 and keeps it.
|
||||
let mut own = Document::new("A");
|
||||
own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap();
|
||||
let file = saved(&own);
|
||||
let feature = file["features"].as_array().and_then(|f| f.last()).cloned().unwrap();
|
||||
assert_eq!((&feature["corner_relief_shape"], &feature["corner_relief"]), (&Value::from("round"), &Value::from("6 mm")));
|
||||
// A build that reads up to 23 asks for an update on that stamp instead of dropping the relief.
|
||||
assert_eq!(version_of(file["format"].as_str().unwrap_or_default()), Some(FORMAT));
|
||||
assert_eq!(read(&file.to_string()).unwrap(), own);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_thirteen_names_keep_their_meaning_under_the_legacy_mark() {
|
||||
let file = serde_json::json!({
|
||||
@@ -1140,7 +1435,7 @@ mod tests {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch,
|
||||
sketch: crate::SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
@@ -1150,9 +1445,9 @@ mod tests {
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: Some("0 deg".into()),
|
||||
to: Some(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)),
|
||||
to: Some(crate::FacePick(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0))),
|
||||
to_offset: Some("offset + 1 mm".into()),
|
||||
from: Some(solidcraft_geom::Vec3::new(0.0, 0.0, 5.0)),
|
||||
from: Some(crate::FacePick(solidcraft_geom::Vec3::new(0.0, 0.0, 5.0))),
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
@@ -1763,4 +2058,28 @@ mod tests {
|
||||
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
|
||||
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
|
||||
}
|
||||
|
||||
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
|
||||
/// options round-trip.
|
||||
#[test]
|
||||
fn sweep_options_are_optional_and_round_trip() {
|
||||
use crate::{FeatureKind, SweepOrientation};
|
||||
let old =
|
||||
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
|
||||
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
|
||||
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), old);
|
||||
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
|
||||
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), old);
|
||||
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
|
||||
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
|
||||
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
|
||||
assert_eq!(*orientation, SweepOrientation::Direction(crate::Dir(solidcraft_geom::Vec3::Y)));
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), new);
|
||||
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
|
||||
assert_eq!(parallel, SweepOrientation::Parallel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Pending design-format changes
|
||||
|
||||
One file per change to what a design file saves that no release has shipped yet. Adding a file
|
||||
here is the whole format bump: never edit `FORMAT`, `JSON_STEPS` or another change's file.
|
||||
|
||||
- Name it after the change, lowercase with dashes: `placed-plane-origins.rs`.
|
||||
- Start it with a `//!` note: what is saved, what an older design gets without it, and what
|
||||
the last release does with a design that has it (it refuses the newer format number).
|
||||
- If older designs must be rewritten as they open, add the step to the same file:
|
||||
|
||||
```rust
|
||||
//! …the note…
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn step(v: &mut Value) -> crate::Result<()> {
|
||||
// rewrite the design's JSON here
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Steps run in file-name order, after the steps of every older format.
|
||||
|
||||
While this folder has a change in it, the build writes the next format number (the last
|
||||
folded one + 1, which no release reads), and lists the changes a file has in its
|
||||
`format_pending` field, so a later build applies only the steps that file is missing. At release
|
||||
time `scripts/fold-format-pending.sh` moves these files into `../v<N>/`, which fixes that number
|
||||
for good. See `crates/doc/src/format.rs` and CONTRIBUTING.md.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! An Import's `file` can name a FreeCAD `.FCStd` or Rhino `.3dm` file (#1435, read through
|
||||
//! cadmpeg); its `step` is still the STEP text the file was restated as, so the feature's fields
|
||||
//! are unchanged and version 22 designs need no transformation; 0.13.x asks for an update before
|
||||
//! it meets these imports.
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A motion link can carry a `coupling`: a gear pair (`ra`, `rb`, `internal`), a rack and pinion
|
||||
//! (`radius`) or a screw (`pitch`), each size a number in mm, an expression or a picked round
|
||||
//! face, and `reverse`. The link's `ratio` is worked out from it whenever the design changes and
|
||||
//! saved as well, so a link reads the same without it. Version 22 designs need no
|
||||
//! transformation: a link without `coupling` is a plain motion link, as it always was; 0.13.0
|
||||
//! (which reads up to 22) asks for an update instead of keeping a gear's ratio when its radius
|
||||
//! changes.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! An Edge Flange feature can choose its own corner relief (`corner_relief_shape`: notch, round,
|
||||
//! rectangular or tear, and `corner_relief`, the size across), for the corners its flanges make.
|
||||
//! Version 23 designs need no transformation: a feature without them follows the sheet metal
|
||||
//! rule, as it always did, and saves as before; 0.13.0 (which reads up to 22) asks for an update
|
||||
//! instead of silently building the rule's relief where a design asks for another.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The Class-A surface pass adds `SurfaceBoundary` (Boundary Surface: `sketch`, `curves`,
|
||||
//! optional `orders`, `crown`, `samples`), `SurfaceMatch` (Match Surface: `body`, optional
|
||||
//! `faces`, `target`, `edges`, optional `order`) and `SurfaceFair` (Fair Surface: `body`, optional
|
||||
//! `faces`, `strength`, `iterations`, `keep_edges`, `tolerance`). Older designs need no
|
||||
//! transformation; 0.13.x asks for an update instead of meeting a feature it can't read.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Rectangular and circular feature patterns store alignment and reversal, independently for
|
||||
//! each rectangular direction. Defaults preserve older positive-direction placements.
|
||||
//! Center retains the source and alternates copies on either side, as sketch patterns do.
|
||||
//! The last release refuses this pending format and asks for an update instead of losing it.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sketch can sit on a plane seen from its other side: `{"type": "flipped", "base": <plane>}`
|
||||
//! (with `"about_y": true` when it turns about the plane's y axis rather than its x axis). New
|
||||
//! sketches started from behind an origin or construction plane save this, so they face the
|
||||
//! viewer. Older designs have no flipped planes and open unchanged; the last release refuses a
|
||||
//! design that has one (it asks for an update) instead of putting the sketch on the wrong side.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A sketch line can be `infinite` (#1359): construction geometry through its two points, drawn
|
||||
//! across the whole view, a boundary for trim and extend along its whole line. Saved only when
|
||||
//! set: older designs need no transformation, and a line without it is the line it always was;
|
||||
//! 0.13.x (which reads up to 24) asks for an update instead of drawing such a line between its
|
||||
//! two points.
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A `Patch` on edges can carry `continuity` (`all`: `g0`, `g1` or `g2`, and `edges`:
|
||||
//! `[{edge: [x, y, z], g}]` for edges met otherwise; G0 everywhere when absent, and then not
|
||||
//! saved) and `points` it passes through (none when absent). Older designs need no
|
||||
//! transformation: a patch without them fills as it always did; a loop that is not a height field
|
||||
//! over its plane, which 0.13.0 refused, now fills with one face. 0.13.x asks for an update
|
||||
//! instead of dropping them.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! An explicit plane (a mirror's, or any plane reference) can be `placed`: an `origin` of three
|
||||
//! length expressions with `x_dir` and `y_dir`, so it follows the design's parameters (#1492). A
|
||||
//! plane whose origin is plain numbers is still saved as `custom`. Older designs need no
|
||||
//! transformation; 0.13.x asks for an update instead of failing to read the plane.
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Sketch linear patterns save two independent live direction-line references and each
|
||||
//! direction's alignment and reversal. Even centered totals keep the original fixed and use
|
||||
//! reversal to choose which side receives the extra copy. Older patterns retain their fixed
|
||||
//! vectors and positive-direction placement through defaulted fields, so no rewrite is needed.
|
||||
//! The last release refuses this pending format instead of silently losing the references.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A Split Body feature can take a straight sketch line as its tool (`sketch`, the sketch's
|
||||
//! feature id, and `line`, the line's id), splitting by the plane through the line along the
|
||||
//! sketch's normal (#1552). Older designs need no transformation: a split without them uses its
|
||||
//! plane or face as before; 0.13.x asks for an update instead of splitting by the stored plane.
|
||||
@@ -424,7 +424,7 @@ mod tests {
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: if missing_sketch { 999_999 } else { id },
|
||||
sketch: crate::SketchId(if missing_sketch { 999_999 } else { id }),
|
||||
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
|
||||
+395
-124
@@ -1,7 +1,8 @@
|
||||
//! Joints between occurrences: joint origins (snap frames on component geometry, re-found on
|
||||
//! every solve so they follow edits), joints (rigid, revolute, slider, cylindrical, pin-slot,
|
||||
//! planar, ball) with their motion values and limits, motion links, and the solver that places
|
||||
//! occurrences from the joint graph.
|
||||
//! planar, ball) with their motion values and limits, motion links (gears, racks and screws are
|
||||
//! links whose ratio comes from radii or a pitch), and the solver that places occurrences from
|
||||
//! the joint graph.
|
||||
//!
|
||||
//! A joint makes B's joint frame coincide with A's frame moved by the joint's motion:
|
||||
//! `T_B · F_b = T_A · F_a · M(q) · Flip`, so `T_B = T_A · F_a · M(q) · Flip · F_b⁻¹`, where `T`
|
||||
@@ -151,6 +152,115 @@ pub struct MotionLink {
|
||||
pub ratio: f64,
|
||||
#[serde(default)]
|
||||
pub offset: f64,
|
||||
/// Gears, a rack and pinion or a screw: what the ratio comes from. The ratio is worked out
|
||||
/// from it again whenever the design changes (`coupling_ratio`), so it follows the radii and
|
||||
/// the pitch; the last one worked out is kept in `ratio`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub coupling: Option<Coupling>,
|
||||
}
|
||||
|
||||
/// A length a coupling's ratio comes from: millimetres, an expression over the design's
|
||||
/// parameters, or the radius of the round face picked on an occurrence (a circle-centre snap,
|
||||
/// found again on every solve like a joint origin).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Size {
|
||||
Length(f64),
|
||||
Expr(String),
|
||||
Circle(JointOrigin),
|
||||
}
|
||||
|
||||
/// The geometry a motion link's ratio comes from. `reverse` turns the follower the other way.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Coupling {
|
||||
/// Two gears on revolute joints, `ra` on a's and `rb` on b's (pitch radii, or any two lengths
|
||||
/// in their proportion such as tooth counts): b turns ra/rb as far as a, the other way round,
|
||||
/// or the same way when one is an internal (ring) gear.
|
||||
Gear {
|
||||
ra: Size,
|
||||
rb: Size,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
internal: bool,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// A pinion of pitch radius `radius` on a revolute joint and a rack on a slider: the rack
|
||||
/// travels the radius times the pinion's turn in radians (either joint may drive).
|
||||
RackPinion {
|
||||
radius: Size,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// A screw: the travel advances `pitch` per turn (a cylindrical joint's angle and distance,
|
||||
/// or a revolute joint and a slider). Right-handed unless reversed.
|
||||
Screw {
|
||||
pitch: Size,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl Coupling {
|
||||
/// The lengths the ratio comes from: a's gear and b's, or the pinion's radius or the pitch.
|
||||
pub fn sizes(&self) -> Vec<&Size> {
|
||||
match self {
|
||||
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
|
||||
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sizes_mut(&mut self) -> Vec<&mut Size> {
|
||||
match self {
|
||||
Coupling::Gear { ra, rb, .. } => vec![ra, rb],
|
||||
Coupling::RackPinion { radius: s, .. } | Coupling::Screw { pitch: s, .. } => vec![s],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest length a coupling takes (mm).
|
||||
const MAX_SIZE: f64 = 1e7;
|
||||
|
||||
/// A coupling length in millimetres: positive and finite.
|
||||
fn size_of(doc: &Document, st: &ModelState, s: &Size) -> Result<f64> {
|
||||
let v = match s {
|
||||
Size::Length(v) => *v,
|
||||
Size::Expr(e) => doc.eval(e, crate::expr::Kind::Length)?,
|
||||
Size::Circle(o) => circle_at(doc, st, o)?.1.radius,
|
||||
};
|
||||
if v.is_finite() && v > 1e-9 && v <= MAX_SIZE {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(DocError::Invalid(format!("a gear, rack or screw needs a positive length, not {v}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The ratio a link's coupling gives it (b's value per a's, in radians and mm), worked out from
|
||||
/// the geometry and parameters it is defined by; `None` for a plain link.
|
||||
pub fn coupling_ratio(doc: &Document, st: &ModelState, l: &MotionLink) -> Option<Result<f64>> {
|
||||
l.coupling.as_ref().map(|c| coupled(doc, st, l, c))
|
||||
}
|
||||
|
||||
fn coupled(doc: &Document, st: &ModelState, l: &MotionLink, c: &Coupling) -> Result<f64> {
|
||||
let angle = |id: u64, i: usize| doc.assembly.joints.iter().find(|j| j.id == id).is_some_and(|j| j.kind.is_angle(i));
|
||||
let (turn_a, turn_b) = (angle(l.a, l.ia), angle(l.b, l.ib));
|
||||
// Travel per radian, for a turn coupled to a travel either way round.
|
||||
let travel = |k: f64| match (turn_a, turn_b) {
|
||||
(true, false) => Ok(k),
|
||||
(false, true) => Ok(1.0 / k),
|
||||
_ => Err(DocError::Invalid("a rack or a screw couples a turn and a travel".into())),
|
||||
};
|
||||
let sign = |reverse: bool| if reverse { -1.0 } else { 1.0 };
|
||||
match c {
|
||||
Coupling::Gear { ra, rb, internal, reverse } => {
|
||||
if !(turn_a && turn_b) {
|
||||
return Err(DocError::Invalid("gears couple two turns".into()));
|
||||
}
|
||||
Ok(size_of(doc, st, ra)? / size_of(doc, st, rb)? * sign(!internal) * sign(*reverse))
|
||||
}
|
||||
Coupling::RackPinion { radius, reverse } => Ok(travel(size_of(doc, st, radius)?)? * sign(*reverse)),
|
||||
Coupling::Screw { pitch, reverse } => Ok(travel(size_of(doc, st, pitch)? / std::f64::consts::TAU)? * sign(*reverse)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Joints, joint origins and motion links of a design.
|
||||
@@ -272,27 +382,35 @@ pub fn resolve_origin(doc: &Document, st: &ModelState, o: &JointOrigin) -> Resul
|
||||
let (_, c, n) = best.ok_or_else(|| DocError::Invalid("joint origin: no planar face there".into()))?;
|
||||
frame(c, n, None).ok_or_else(bad)
|
||||
}
|
||||
Snap::CircleCenter { pick, face } => {
|
||||
let pick = &named_pick(st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let cyl = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, *pick))
|
||||
.min_by(|a, b| {
|
||||
let off = |c: &solidcraft_kernel::CylinderFace| {
|
||||
let v = *pick - c.axis_point;
|
||||
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
|
||||
};
|
||||
off(a).total_cmp(&off(b))
|
||||
})
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
|
||||
let s = (*pick - cyl.axis_point).dot(cyl.axis);
|
||||
Snap::CircleCenter { .. } => {
|
||||
let (pick, cyl) = circle_at(doc, st, o)?;
|
||||
let s = (pick - cyl.axis_point).dot(cyl.axis);
|
||||
let (at, dir) = if (s - cyl.start).abs() <= (cyl.end - s).abs() { (cyl.start, -1.0) } else { (cyl.end, 1.0) };
|
||||
frame(cyl.axis_point + cyl.axis * at, cyl.axis * dir, None).ok_or_else(bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick of a circle-centre snap (on its named face) and the cylindrical face there
|
||||
/// (component coordinates). Any other snap is refused.
|
||||
fn circle_at(doc: &Document, st: &ModelState, o: &JointOrigin) -> Result<(Vec3, solidcraft_kernel::CylinderFace)> {
|
||||
let Snap::CircleCenter { pick, face } = &o.snap else { return Err(DocError::Invalid("pick a round face".into())) };
|
||||
let pick = named_pick(st, *pick, face.as_deref())?;
|
||||
let comp = component_of(doc, o.occurrence)?;
|
||||
let cyl = component_bodies(doc, st, comp)
|
||||
.into_iter()
|
||||
.filter_map(|b| solidcraft_kernel::cylinder_face_at(&b.body, pick))
|
||||
.min_by(|a, b| {
|
||||
let off = |c: &solidcraft_kernel::CylinderFace| {
|
||||
let v = pick - c.axis_point;
|
||||
((v - c.axis * v.dot(c.axis)).len() - c.radius).abs()
|
||||
};
|
||||
off(a).total_cmp(&off(b))
|
||||
})
|
||||
.ok_or_else(|| DocError::Invalid("joint origin: no cylindrical face there".into()))?;
|
||||
Ok((pick, cyl))
|
||||
}
|
||||
|
||||
/// World transform of an occurrence (through its parents).
|
||||
pub fn occurrence_world(doc: &Document, occ: u64) -> Mat {
|
||||
let mut m = IDENTITY;
|
||||
@@ -624,6 +742,16 @@ fn unknowns(tree: &Tree, around: &BTreeSet<u64>, asm: &Assembly, linked: &BTreeS
|
||||
vars
|
||||
}
|
||||
|
||||
/// Each value's limits, or none.
|
||||
fn limit_bounds(tree: &Tree, vars: &[(u64, usize)]) -> Vec<(f64, f64)> {
|
||||
vars.iter()
|
||||
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
|
||||
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
|
||||
None => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The loops' size: the diagonal of the box around their joints' A frames, 1 mm to 1 km. Points
|
||||
/// that far apart weigh turns like moves.
|
||||
fn loop_scale(tree: &Tree, around: &BTreeSet<u64>, vals: &Values) -> f64 {
|
||||
@@ -658,11 +786,72 @@ fn closure_gaps(tree: &Tree, closing: &[u64], vals: &Values, scale: f64, out: &m
|
||||
/// keep the tree's count.
|
||||
const MAX_DOF_OCCURRENCES: usize = 256;
|
||||
|
||||
/// How far `nearby_pose` moves each value: radians for an angle, a share of the loops' size for a
|
||||
/// distance. Far enough for a part's motion that vanishes at a dead point to show in a rank, near
|
||||
/// enough to stay on the mechanism's branch.
|
||||
const NUDGE: f64 = 1e-3;
|
||||
|
||||
/// A pose of the loops near `vals`: each value they move nudged by an amount of its own within
|
||||
/// its limits (so the nudge points in no special direction), then each group of loops closed
|
||||
/// again. A group that can't close again, or only further away than a few nudges (on another
|
||||
/// branch), keeps its values. It has a work budget of its own.
|
||||
fn nearby_pose(tree: &Tree, groups: &[Group], asm: &Assembly, linked: &BTreeSet<(u64, usize)>, vals: &Values) -> Values {
|
||||
let mut near = vals.clone();
|
||||
let mut spent = 0usize;
|
||||
for (closing, around, _) in groups {
|
||||
let vars = unknowns(tree, around, asm, linked, &Held::new());
|
||||
if vars.is_empty() || vars.len() > MAX_LOOP_VALUES {
|
||||
continue;
|
||||
}
|
||||
let scale = loop_scale(tree, around, vals);
|
||||
let step: Vec<f64> =
|
||||
vars.iter().map(|(id, i)| NUDGE * if tree.joints.get(id).is_some_and(|j| j.kind.is_angle(*i)) { 1.0 } else { scale }).collect();
|
||||
let bounds = limit_bounds(tree, &vars);
|
||||
let x0: Vec<f64> = vars.iter().map(|(id, i)| near.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
let mut x: Vec<f64> = x0
|
||||
.iter()
|
||||
.zip(step.iter().zip(&bounds))
|
||||
.enumerate()
|
||||
.map(|(k, (v, (d, (lo, hi))))| {
|
||||
// 0.5 to 1.5 nudges, alternately up and down.
|
||||
let w = 0.5 + (k as f64 * 0.618_034).fract();
|
||||
(v + d * if k % 2 == 0 { w } else { -w }).max(*lo).min(*hi)
|
||||
})
|
||||
.collect();
|
||||
let mut work = near.clone();
|
||||
let mut gaps = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
spent += 1;
|
||||
if spent > MAX_LOOP_WORK {
|
||||
return None;
|
||||
}
|
||||
for ((id, i), v) in vars.iter().zip(x) {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
apply_links(asm, &mut work);
|
||||
let mut out = Vec::with_capacity(closing.len() * 9);
|
||||
closure_gaps(tree, closing, &work, scale, &mut out)?;
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
if least_squares(&mut gaps, &mut x, &bounds, (1e-10 * scale).min(1e-7))
|
||||
&& x.iter().zip(&x0).zip(&step).all(|((a, b), d)| (a - b).abs() <= 4.0 * d)
|
||||
&& gaps(&x).is_some()
|
||||
{
|
||||
near = work;
|
||||
}
|
||||
}
|
||||
near
|
||||
}
|
||||
|
||||
/// The degrees of freedom of the occurrences loops constrain: how many independent ways each can
|
||||
/// still move with every loop kept closed, the rank of its placement over the joint values once
|
||||
/// the loops' closure is taken out (rank [J_loops; J_placement] - rank J_loops). An occurrence's
|
||||
/// values are those on its tree path and the unknowns of the loops on that path. The tree's
|
||||
/// count stands where a loop group is over the solver's caps or the work runs out.
|
||||
/// values are those on its tree path and the unknowns of the loops on that path. It is counted at
|
||||
/// the pose and at a pose nearby, and the larger count stands: at a dead point of its loop (an
|
||||
/// actuator in line with the path of its tip pin) a part's motion vanishes to first order, so the
|
||||
/// pose alone counts 0. The tree's count stands where a loop group is over the solver's caps or
|
||||
/// the work runs out.
|
||||
fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &mut BTreeMap<u64, usize>) {
|
||||
if closing.len() > MAX_LOOPS {
|
||||
return;
|
||||
@@ -690,6 +879,8 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
|
||||
}
|
||||
}
|
||||
let (mut spent, mut counted) = (0usize, 0usize);
|
||||
// A pose nearby, when one differs.
|
||||
let near = (!candidates.is_empty()).then(|| nearby_pose(tree, &groups, asm, &linked, vals)).filter(|n| n != vals);
|
||||
for occ in tree.order.iter().filter(|o| candidates.contains(o)) {
|
||||
let path: Vec<u64> = tree.path(*occ).1.iter().map(|(k, _)| *k).collect();
|
||||
let (mut values, mut have): (Vec<(u64, usize)>, BTreeSet<(u64, usize)>) = (Vec::new(), BTreeSet::new());
|
||||
@@ -751,41 +942,47 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
|
||||
continue;
|
||||
}
|
||||
let scale = loop_scale(tree, &around, vals);
|
||||
let mut work = vals.clone();
|
||||
// The loops' gaps, then three points of the occurrence `scale` apart.
|
||||
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
spent += 1;
|
||||
for ((id, i), v) in values.iter().zip(x) {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
apply_links(asm, &mut work);
|
||||
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
|
||||
closure_gaps(tree, &closers, &work, scale, &mut out)?;
|
||||
let t = tree.world(*occ, &work)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let q = apply_point(&t, p);
|
||||
out.extend([q.x, q.y, q.z]);
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
let x0: Vec<f64> = values.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
let (n, gaps) = (values.len(), closers.len() * 9);
|
||||
let Some(jac) = jacobian(&mut f, &x0, gaps + 9) else { continue };
|
||||
let rank = |rows: usize| {
|
||||
let mut a = linalg::Mat::zeros(rows, n);
|
||||
let mut big = 0.0f64;
|
||||
for r in 0..rows {
|
||||
for c in 0..n {
|
||||
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
|
||||
big = big.max(v.abs());
|
||||
a.set(r, c, v);
|
||||
// How many ways the occurrence moves at a pose, with the loops kept closed.
|
||||
let mut free_at = |at: &Values| -> Option<usize> {
|
||||
let mut work = at.clone();
|
||||
// The loops' gaps, then three points of the occurrence `scale` apart.
|
||||
let mut f = |x: &[f64]| -> Option<Vec<f64>> {
|
||||
spent += 1;
|
||||
for ((id, i), v) in values.iter().zip(x) {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
|
||||
apply_links(asm, &mut work);
|
||||
let mut out = Vec::with_capacity(closers.len() * 9 + 9);
|
||||
closure_gaps(tree, &closers, &work, scale, &mut out)?;
|
||||
let t = tree.world(*occ, &work)?;
|
||||
for p in [Vec3::ZERO, Vec3::X * scale, Vec3::Y * scale] {
|
||||
let q = apply_point(&t, p);
|
||||
out.extend([q.x, q.y, q.z]);
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
};
|
||||
let x0: Vec<f64> = values.iter().map(|(id, i)| at.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
let jac = jacobian(&mut f, &x0, gaps + 9)?;
|
||||
let rank = |rows: usize| {
|
||||
let mut a = linalg::Mat::zeros(rows, n);
|
||||
let mut big = 0.0f64;
|
||||
for r in 0..rows {
|
||||
for c in 0..n {
|
||||
let v = jac.get(r * n + c).copied().unwrap_or(0.0);
|
||||
big = big.max(v.abs());
|
||||
a.set(r, c, v);
|
||||
}
|
||||
}
|
||||
linalg::rref(&mut a, 1e-6 * big.max(1.0)).len()
|
||||
};
|
||||
Some(rank(gaps + 9).saturating_sub(rank(gaps)))
|
||||
};
|
||||
let free = rank(gaps + 9).saturating_sub(rank(gaps));
|
||||
let here = free_at(vals);
|
||||
let there = near.as_ref().and_then(&mut free_at);
|
||||
let Some(free) = here.max(there) else { continue };
|
||||
dof.insert(*occ, free.min(6));
|
||||
}
|
||||
}
|
||||
@@ -814,13 +1011,7 @@ fn close_group(
|
||||
if vars.len() > MAX_LOOP_VALUES {
|
||||
return Some(BTreeMap::new());
|
||||
}
|
||||
let bounds: Vec<(f64, f64)> = vars
|
||||
.iter()
|
||||
.map(|(id, i)| match tree.joints.get(id).and_then(|j| j.limits.get(*i).copied().flatten()) {
|
||||
Some((lo, hi)) => (lo.min(hi), hi.max(lo)),
|
||||
None => (f64::NEG_INFINITY, f64::INFINITY),
|
||||
})
|
||||
.collect();
|
||||
let bounds = limit_bounds(tree, &vars);
|
||||
let scale = loop_scale(tree, around, vals);
|
||||
// Closer than the conflict check needs: 1e-7 mm, and turns of 1e-10 rad across the loop.
|
||||
let tol = (1e-10 * scale).min(1e-7);
|
||||
@@ -1020,72 +1211,8 @@ pub fn solve(doc: &Document, st: &ModelState) -> Solution {
|
||||
pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
let asm = &doc.assembly;
|
||||
let mut sol = Solution::default();
|
||||
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
|
||||
let mut vals = joint_values(asm);
|
||||
// Joint frames in component coordinates (errors leave the joint out).
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
for j in &joints {
|
||||
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
|
||||
(Ok(fa), Ok(fb)) => {
|
||||
frames.insert(j.id, (fa, fb));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => sol.errors.push((j.id, j.name.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
// Fixed: the root, grounded occurrences; with neither involved, the first joint's A side.
|
||||
let mut placed: BTreeMap<u64, Mat> = BTreeMap::new();
|
||||
let mut dof: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
placed.insert(0, IDENTITY);
|
||||
dof.insert(0, 0);
|
||||
for o in doc.occurrences.iter().filter(|o| o.grounded) {
|
||||
placed.insert(o.id, occurrence_world(doc, o.id));
|
||||
dof.insert(o.id, 0);
|
||||
}
|
||||
let involved: BTreeSet<u64> = joints.iter().flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect();
|
||||
if !involved.iter().any(|o| placed.contains_key(o))
|
||||
&& let Some(j) = joints.first()
|
||||
{
|
||||
placed.insert(j.a.occurrence, occurrence_world(doc, j.a.occurrence));
|
||||
dof.insert(j.a.occurrence, 0);
|
||||
}
|
||||
sol.fixed = placed.keys().copied().collect();
|
||||
let mut tree =
|
||||
// Every joint's values are known, suppressed ones too (their motion links still set values).
|
||||
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
|
||||
let mut used: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
|
||||
while let Some(occ) = queue.pop_front() {
|
||||
let Some(t) = placed.get(&occ).copied() else { continue };
|
||||
for j in &joints {
|
||||
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
|
||||
continue;
|
||||
}
|
||||
let (other, from_a) = if j.a.occurrence == occ {
|
||||
(j.b.occurrence, true)
|
||||
} else if j.b.occurrence == occ {
|
||||
(j.a.occurrence, false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if placed.contains_key(&other) {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = tree.step(j.id, from_a, &t, &vals) else { continue };
|
||||
used.insert(j.id);
|
||||
placed.insert(other, m);
|
||||
tree.parent.insert(other, (occ, j.id, from_a));
|
||||
tree.order.push(other);
|
||||
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
|
||||
queue.push_back(other);
|
||||
}
|
||||
}
|
||||
// Joints that close loops: move the loops' values to meet them, then check they agree.
|
||||
let closing: Vec<u64> = joints
|
||||
.iter()
|
||||
.filter(|j| !used.contains(&j.id) && tree.frames.contains_key(&j.id))
|
||||
.filter(|j| placed.contains_key(&j.a.occurrence) && placed.contains_key(&j.b.occurrence))
|
||||
.map(|j| j.id)
|
||||
.collect();
|
||||
let Built { tree, mut placed, closing, mut dof } = build(doc, st, &vals, &mut sol);
|
||||
let mut stuck = BTreeSet::new();
|
||||
if !closing.is_empty() {
|
||||
(sol.values, stuck) = close_loops(&tree, &closing, asm, held, &mut vals);
|
||||
@@ -1123,6 +1250,99 @@ pub fn solve_holding(doc: &Document, st: &ModelState, held: &Held) -> Solution {
|
||||
sol
|
||||
}
|
||||
|
||||
/// The joint graph laid out for a solve: the tree, where it places each occurrence for the
|
||||
/// values it was built with, the joints that close loops, and each occurrence's degrees of
|
||||
/// freedom along the tree.
|
||||
struct Built<'a> {
|
||||
tree: Tree<'a>,
|
||||
placed: BTreeMap<u64, Mat>,
|
||||
closing: Vec<u64>,
|
||||
dof: BTreeMap<u64, usize>,
|
||||
}
|
||||
|
||||
/// Lay out the joint graph: the spanning tree out from the fixed occurrences (the root, grounded
|
||||
/// ones; with neither involved, the first joint's A side) and the joints left over, which close
|
||||
/// loops. Joints whose origins can't be resolved go to `sol.errors`; `sol.fixed` is set.
|
||||
fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Solution) -> Built<'a> {
|
||||
let asm = &doc.assembly;
|
||||
let joints: Vec<&Joint> = asm.joints.iter().filter(|j| !j.suppressed).take(MAX_JOINTS).collect();
|
||||
// Joint frames in component coordinates (errors leave the joint out).
|
||||
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
|
||||
for j in &joints {
|
||||
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
|
||||
(Ok(fa), Ok(fb)) => {
|
||||
frames.insert(j.id, (fa, fb));
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => sol.errors.push((j.id, j.name.clone(), e.to_string())),
|
||||
}
|
||||
}
|
||||
// Fixed: the root, grounded occurrences; with neither involved, the first joint's A side.
|
||||
let mut placed: BTreeMap<u64, Mat> = BTreeMap::new();
|
||||
let mut dof: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
placed.insert(0, IDENTITY);
|
||||
dof.insert(0, 0);
|
||||
for o in doc.occurrences.iter().filter(|o| o.grounded) {
|
||||
placed.insert(o.id, occurrence_world(doc, o.id));
|
||||
dof.insert(o.id, 0);
|
||||
}
|
||||
let involved: BTreeSet<u64> = joints.iter().flat_map(|j| [j.a.occurrence, j.b.occurrence]).collect();
|
||||
if !involved.iter().any(|o| placed.contains_key(o))
|
||||
&& let Some(j) = joints.first()
|
||||
{
|
||||
placed.insert(j.a.occurrence, occurrence_world(doc, j.a.occurrence));
|
||||
dof.insert(j.a.occurrence, 0);
|
||||
}
|
||||
sol.fixed = placed.keys().copied().collect();
|
||||
let mut tree =
|
||||
// Every joint's values are known, suppressed ones too (their motion links still set values).
|
||||
Tree { joints: asm.joints.iter().map(|j| (j.id, j)).collect(), frames, fixed: placed.clone(), parent: BTreeMap::new(), order: Vec::new() };
|
||||
let mut used: BTreeSet<u64> = BTreeSet::new();
|
||||
let mut queue: VecDeque<u64> = placed.keys().copied().collect();
|
||||
// The joints at each occurrence, in the joints' order (a large assembly is not walked joint by
|
||||
// joint for every occurrence).
|
||||
let mut at: BTreeMap<u64, Vec<&Joint>> = BTreeMap::new();
|
||||
for j in joints.iter().copied() {
|
||||
at.entry(j.a.occurrence).or_default().push(j);
|
||||
if j.b.occurrence != j.a.occurrence {
|
||||
at.entry(j.b.occurrence).or_default().push(j);
|
||||
}
|
||||
}
|
||||
while let Some(occ) = queue.pop_front() {
|
||||
let Some(t) = placed.get(&occ).copied() else { continue };
|
||||
for j in at.get(&occ).map(Vec::as_slice).unwrap_or_default() {
|
||||
if used.contains(&j.id) || !tree.frames.contains_key(&j.id) {
|
||||
continue;
|
||||
}
|
||||
let (other, from_a) = if j.a.occurrence == occ {
|
||||
(j.b.occurrence, true)
|
||||
} else if j.b.occurrence == occ {
|
||||
(j.a.occurrence, false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if placed.contains_key(&other) {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = tree.step(j.id, from_a, &t, vals) else { continue };
|
||||
used.insert(j.id);
|
||||
placed.insert(other, m);
|
||||
tree.parent.insert(other, (occ, j.id, from_a));
|
||||
tree.order.push(other);
|
||||
dof.insert(other, dof.get(&occ).copied().unwrap_or(0) + j.kind.dofs().len());
|
||||
queue.push_back(other);
|
||||
}
|
||||
}
|
||||
// Joints that close loops: the solve moves the loops' values to meet them, then checks they
|
||||
// agree.
|
||||
let closing: Vec<u64> = joints
|
||||
.iter()
|
||||
.filter(|j| !used.contains(&j.id) && tree.frames.contains_key(&j.id))
|
||||
.filter(|j| placed.contains_key(&j.a.occurrence) && placed.contains_key(&j.b.occurrence))
|
||||
.map(|j| j.id)
|
||||
.collect();
|
||||
Built { tree, placed, closing, dof }
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Apply a solution: occurrence transforms (in their parents) for the occurrences it places,
|
||||
/// and the values of the joints that moved to close loops. Returns true when something moved.
|
||||
@@ -1168,6 +1388,10 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "joints_reach.rs"]
|
||||
mod reach;
|
||||
pub use reach::{REACH_TOL, Reach, Reached, reach};
|
||||
|
||||
/// The frame matrix for an explicit origin / z / x (public for commands).
|
||||
pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
|
||||
frame(o, z, x)
|
||||
@@ -1201,7 +1425,7 @@ mod tests {
|
||||
suppressed: false,
|
||||
})
|
||||
.collect();
|
||||
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0 }).collect();
|
||||
let links = (1..n).rev().map(|a| MotionLink { a, ia: 0, b: a + 1, ib: 0, ratio: 2.0, offset: 0.0, coupling: None }).collect();
|
||||
Assembly { joints, links, ..Assembly::default() }
|
||||
}
|
||||
|
||||
@@ -1224,6 +1448,53 @@ mod tests {
|
||||
assert_eq!(again, vals);
|
||||
}
|
||||
|
||||
/// A coupling saves as its definition: sizes as numbers, expressions or picked faces, flags
|
||||
/// only when set; a link without one (as every design before couplings has) reads as before.
|
||||
#[test]
|
||||
fn couplings_read_and_write_their_definition() {
|
||||
let old: MotionLink = serde_json::from_str(r#"{"a": 1, "b": 2, "ratio": 2}"#).unwrap();
|
||||
assert_eq!((old.coupling, old.offset, old.ia), (None, 0.0, 0));
|
||||
let text = r#"{"a": 1, "b": 2, "ratio": -0.5, "coupling": {"type": "gear", "ra": 10, "rb": "r2 * 2",
|
||||
"internal": true}}"#;
|
||||
let l: MotionLink = serde_json::from_str(text).unwrap();
|
||||
let Some(Coupling::Gear { ra, rb, internal, reverse }) = &l.coupling else { panic!("{l:?}") };
|
||||
assert_eq!((ra, rb, *internal, *reverse), (&Size::Length(10.0), &Size::Expr("r2 * 2".into()), true, false));
|
||||
let back = serde_json::to_value(&l).unwrap();
|
||||
assert_eq!(back["coupling"], serde_json::json!({"type": "gear", "ra": 10.0, "rb": "r2 * 2", "internal": true}));
|
||||
let picked: Size = serde_json::from_str(r#"{"occurrence": 3, "snap": {"type": "circle_center", "pick": [1, 0, 0]}}"#).unwrap();
|
||||
assert!(matches!(picked, Size::Circle(JointOrigin { occurrence: 3, snap: Snap::CircleCenter { .. } })));
|
||||
let screw: Coupling = serde_json::from_str(r#"{"type": "screw", "pitch": 1.5, "reverse": true}"#).unwrap();
|
||||
assert_eq!(screw, Coupling::Screw { pitch: Size::Length(1.5), reverse: true });
|
||||
}
|
||||
|
||||
/// Ratios from the sizes: gears turn by their radii (the other way unless internal), a rack
|
||||
/// and a screw travel per radian whichever of the two joints drives.
|
||||
#[test]
|
||||
fn coupling_ratios_follow_their_sizes() {
|
||||
let mut doc = Document { assembly: chain(3, 0.0), ..Document::default() };
|
||||
if let Some(j) = doc.assembly.joints.get_mut(2) {
|
||||
j.kind = JointKind::Slider;
|
||||
}
|
||||
let st = ModelState::default();
|
||||
let ratio = |a: u64, b: u64, c: Coupling| {
|
||||
let l = MotionLink { a, ia: 0, b, ib: 0, ratio: 0.0, offset: 0.0, coupling: Some(c) };
|
||||
coupling_ratio(&doc, &st, &l).map(|r| r.map_err(|e| e.to_string()))
|
||||
};
|
||||
let len = Size::Length;
|
||||
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: false, reverse: false }), Some(Ok(-0.25)));
|
||||
assert_eq!(ratio(1, 2, Coupling::Gear { ra: len(10.0), rb: len(40.0), internal: true, reverse: false }), Some(Ok(0.25)));
|
||||
assert_eq!(ratio(1, 3, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Ok(8.0)));
|
||||
assert_eq!(ratio(3, 1, Coupling::RackPinion { radius: len(8.0), reverse: true }), Some(Ok(-0.125)));
|
||||
let screw = ratio(1, 3, Coupling::Screw { pitch: len(std::f64::consts::TAU), reverse: false });
|
||||
assert_eq!(screw, Some(Ok(1.0)));
|
||||
assert!(matches!(ratio(1, 2, Coupling::RackPinion { radius: len(8.0), reverse: false }), Some(Err(_))));
|
||||
assert!(matches!(ratio(1, 3, Coupling::Gear { ra: len(1.0), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
|
||||
for bad in [0.0, -2.0, f64::NAN, f64::INFINITY, 1e300] {
|
||||
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: len(bad), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))), "{bad}");
|
||||
}
|
||||
assert!(matches!(ratio(1, 2, Coupling::Gear { ra: Size::Expr("nope".into()), rb: len(1.0), internal: false, reverse: false }), Some(Err(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motions_compose() {
|
||||
assert!(close(&motion(JointKind::Revolute, &[std::f64::consts::FRAC_PI_2]), &rot_z(std::f64::consts::FRAC_PI_2)));
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Inverse kinematics (`joint.reach`): move the values of the joints on an occurrence's chain so a
|
||||
//! point of it reaches a target (and an axis of it points a given way), every value within its
|
||||
//! limits, motion links followed, and the closed loops those values are in kept closed.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_sketch::linalg;
|
||||
|
||||
use super::{
|
||||
Assembly, Held, MAX_LOOP_VALUES, MAX_LOOP_WORK, MAX_LOOPS, Solution, Tree, Values, apply_links, build, closure_gaps, jacobian, joint_values,
|
||||
largest, least_squares, limit_bounds, link_chains, loop_groups, loop_scale, unknowns,
|
||||
};
|
||||
use crate::{Document, ModelState, apply_point, apply_vector, mat_mul};
|
||||
|
||||
/// How close counts as reached: mm for the point, degrees for the axis.
|
||||
pub const REACH_TOL: f64 = 1e-4;
|
||||
|
||||
/// What a reach asks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Reach {
|
||||
/// The occurrence that reaches.
|
||||
pub occurrence: u64,
|
||||
/// Its point that goes to `to`, in its own (component) coordinates.
|
||||
pub point: Vec3,
|
||||
/// Where the point goes (world).
|
||||
pub to: Vec3,
|
||||
/// A unit axis of it (its own coordinates) and the unit world direction it turns to.
|
||||
pub axis: Option<(Vec3, Vec3)>,
|
||||
/// The joints whose values move toward the target (`None`: every joint on its chain). The
|
||||
/// other joints of a closed loop they are in follow.
|
||||
pub joints: Option<BTreeSet<u64>>,
|
||||
}
|
||||
|
||||
/// How a reach ended.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Reached {
|
||||
/// The new stored values of the joints that moved (joint id → values).
|
||||
pub values: Values,
|
||||
/// Short of the target: the values it ended on a limit of, with the target pulling past it
|
||||
/// (joint, value index, the upper limit).
|
||||
pub limits: Vec<(u64, usize, bool)>,
|
||||
/// Short of the target: the closing joint of a loop that stopped it (the chain alone, the
|
||||
/// loop left open, gets closer).
|
||||
pub stopped_by_loop: Option<u64>,
|
||||
}
|
||||
|
||||
/// How much a loop's gap (mm) outweighs a miss of the target (mm) in a step: enough to keep the
|
||||
/// step along the mechanism, so putting it back on the loops moves it little.
|
||||
const LOOP_WEIGHT: f64 = 1e3;
|
||||
/// Steps a reach takes at most, each moving no value more than 10° or a tenth of the chain's
|
||||
/// size (so it follows the mechanism's branch).
|
||||
const MAX_REACH_STEPS: usize = 200;
|
||||
const STEP_ANGLE: f64 = 10.0;
|
||||
const STEP_SHARE: f64 = 0.1;
|
||||
|
||||
/// Move the values of the joints on `r.occurrence`'s chain (those in `r.joints`, when given) so
|
||||
/// its point gets to `r.to`, or as close as the limits and closed loops let it: the nearest pose,
|
||||
/// found from where it is (it stays on the mechanism's branch). Values a motion link sets move
|
||||
/// through the start of their link chain; the other values of the loops they are in follow. An
|
||||
/// error says why nothing can move.
|
||||
pub fn reach(doc: &Document, st: &ModelState, r: &Reach) -> Result<Reached, String> {
|
||||
let asm = &doc.assembly;
|
||||
let vals = joint_values(asm);
|
||||
let built = build(doc, st, &vals, &mut Solution::default());
|
||||
let tree = &built.tree;
|
||||
if tree.fixed.contains_key(&r.occurrence) {
|
||||
return Err("it is fixed in place (grounded, or the part its joints are placed from)".into());
|
||||
}
|
||||
if !tree.parent.contains_key(&r.occurrence) {
|
||||
return Err("no joint places it: it is not connected to anything grounded (ground a part, or join it to a grounded part)".into());
|
||||
}
|
||||
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
|
||||
let allowed = |id: &u64| r.joints.as_ref().is_none_or(|js| js.contains(id));
|
||||
let path: BTreeSet<u64> = tree.path(r.occurrence).1.iter().map(|(k, _)| *k).collect();
|
||||
// The values that move it: those of the joints on its chain (the ones given), a linked one
|
||||
// through the start of its link chain (given or not), and the starts given of the chain's
|
||||
// other linked values.
|
||||
let chosen: BTreeSet<u64> = path.iter().copied().filter(allowed).collect();
|
||||
let mut drive: Vec<(u64, usize)> = Vec::new();
|
||||
for j in chosen.iter().filter_map(|id| tree.joints.get(id)) {
|
||||
drive.extend((0..j.kind.dofs().len()).map(|i| (j.id, i)).filter(|v| !linked.contains(v)));
|
||||
}
|
||||
let starts = link_chains(asm, tree, &path, &linked).1.into_iter().filter(|v| allowed(&v.0));
|
||||
for v in link_chains(asm, tree, &chosen, &linked).1.into_iter().chain(starts) {
|
||||
if !drive.contains(&v) {
|
||||
drive.push(v);
|
||||
}
|
||||
}
|
||||
if drive.is_empty() {
|
||||
return Err(if r.joints.is_some() {
|
||||
"none of the joints given moves it"
|
||||
} else {
|
||||
"no joint on its chain moves (rigid, or set by motion links)"
|
||||
}
|
||||
.into());
|
||||
}
|
||||
// The loops those values are in, and the values of theirs that follow.
|
||||
if built.closing.len() > MAX_LOOPS {
|
||||
return Err(format!("the design has more than {MAX_LOOPS} closed loops"));
|
||||
}
|
||||
let groups = loop_groups(tree, &built.closing, asm, &linked);
|
||||
let (mut vars, mut closers, mut around) = (drive.clone(), Vec::new(), path.clone());
|
||||
let mut taken = BTreeSet::new();
|
||||
while vars.len() <= MAX_LOOP_VALUES {
|
||||
let more: Vec<usize> =
|
||||
groups.iter().enumerate().filter(|(g, (_, _, t))| !taken.contains(g) && t.iter().any(|v| vars.contains(v))).map(|(g, _)| g).collect();
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (c, a, _) in more.iter().filter_map(|g| groups.get(*g)) {
|
||||
closers.extend(c.iter().copied());
|
||||
around.extend(a.iter().copied());
|
||||
for v in unknowns(tree, a, asm, &linked, &Held::new()) {
|
||||
if !vars.contains(&v) {
|
||||
vars.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
taken.extend(more);
|
||||
}
|
||||
if vars.len() > MAX_LOOP_VALUES {
|
||||
return Err(format!("its chain and loops have more than {MAX_LOOP_VALUES} values"));
|
||||
}
|
||||
let scale = loop_scale(tree, &around, &vals);
|
||||
let bounds = limit_bounds(tree, &vars);
|
||||
let x0: Vec<f64> = vars.iter().map(|(id, i)| vals.get(id).and_then(|q| q.get(*i)).copied().unwrap_or(0.0)).collect();
|
||||
// A distance steps by a share of the chain's size, of the way to the target or of the point's
|
||||
// reach from its joints, whichever is longest (one slider on its own has no size, and a part
|
||||
// turning about a joint far from its point moves that far).
|
||||
let point = tree.world(r.occurrence, &vals).map(|t| apply_point(&t, r.point));
|
||||
let lever = path
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let (j, (fa, _)) = (tree.joints.get(id)?, tree.frames.get(id)?);
|
||||
Some(apply_point(&mat_mul(&tree.world(j.a.occurrence, &vals)?, fa), Vec3::ZERO).dist(point?))
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
let span = point.map_or(scale, |p| scale.max(p.dist(r.to)).max(lever));
|
||||
let cap: Vec<f64> = vars
|
||||
.iter()
|
||||
.map(|(id, i)| if tree.joints.get(id).is_some_and(|j| j.kind.is_angle(*i)) { STEP_ANGLE.to_radians() } else { STEP_SHARE * span })
|
||||
.collect();
|
||||
// Every value around the chain and its loops, links included, moves at most a step at a time:
|
||||
// a value a link sets can turn far more than the one that drives it, and a loop must stay
|
||||
// closed on the way, as joint.drive steps its loops.
|
||||
let mut paced: Vec<((u64, usize), f64)> = Vec::new();
|
||||
for j in around.iter().filter_map(|id| tree.joints.get(id)) {
|
||||
for i in 0..j.kind.dofs().len() {
|
||||
paced.push(((j.id, i), if j.kind.is_angle(i) { STEP_ANGLE.to_radians() } else { STEP_SHARE * span }));
|
||||
}
|
||||
}
|
||||
let tol = (1e-10 * scale).min(1e-7);
|
||||
let mt = if r.axis.is_some() { 6 } else { 3 };
|
||||
let spent = Cell::new(0usize);
|
||||
let mut work = vals.clone();
|
||||
let mut f = |x: &[f64]| misses(tree, asm, r, scale, &vars, &closers, x, &mut work, &spent);
|
||||
let mut x = x0.clone();
|
||||
let miss = |res: &[f64]| res.iter().take(mt).map(|v| v * v).sum::<f64>().sqrt();
|
||||
let before = f(&x0).map_or(f64::INFINITY, |r| miss(&r));
|
||||
let res = approach(&mut f, &|a, b| stride(asm, &vals, &vars, &paced, a, b), mt, &mut x, &bounds, &cap, tol)
|
||||
.ok_or("a closed loop on its chain is open and can't close (joint.list shows its conflict)")?;
|
||||
let mut out = Reached::default();
|
||||
let point_miss = res.iter().take(3).map(|v| v * v).sum::<f64>().sqrt();
|
||||
let turn_miss = (res.iter().skip(3).take(mt - 3).map(|v| v * v).sum::<f64>().sqrt() / scale).to_degrees();
|
||||
if point_miss > REACH_TOL || turn_miss > REACH_TOL {
|
||||
// The limits it rests on: the target pulls the value past them (−Jᵀr over the target's
|
||||
// rows, ignoring pulls at right angles to the miss: under a millionth of the value's own
|
||||
// reach times the miss).
|
||||
let n = x.len();
|
||||
if let Some(jac) = jacobian(&mut f, &x, res.len()) {
|
||||
for (k, (((id, i), v), (lo, hi))) in vars.iter().zip(&x).zip(&bounds).enumerate() {
|
||||
let column = |row: usize| jac.get(row * n + k).copied().unwrap_or(0.0);
|
||||
let pull: f64 = -(0..mt).map(|row| column(row) * res.get(row).copied().unwrap_or(0.0)).sum::<f64>();
|
||||
let eps = 1e-6 * (0..mt).map(|row| column(row).powi(2)).sum::<f64>().sqrt() * miss(&res);
|
||||
let at = |b: f64| b.is_finite() && (v - b).abs() <= 1e-9 * b.abs().max(1.0);
|
||||
if at(*hi) && pull > eps {
|
||||
out.limits.push((*id, *i, true));
|
||||
} else if at(*lo) && pull < -eps {
|
||||
out.limits.push((*id, *i, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A value a motion link sets, held at its own limit (the link clamps it there): the
|
||||
// target's pull on it as if it were free.
|
||||
if f(&x).is_some() {
|
||||
for (id, i) in linked.iter().filter(|(id, _)| around.contains(id)) {
|
||||
let (Some(v), Some((lo, hi))) = (work.get(id).and_then(|q| q.get(*i)).copied(), limit_bounds(tree, &[(*id, *i)]).first().copied())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let at = |b: f64| b.is_finite() && (v - b).abs() <= 1e-9 * b.abs().max(1.0);
|
||||
if !at(lo) && !at(hi) {
|
||||
continue;
|
||||
}
|
||||
let nudged = |dv: f64| {
|
||||
let mut w = work.clone();
|
||||
if let Some(slot) = w.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = v + dv;
|
||||
}
|
||||
target(tree, r, scale, &w)
|
||||
};
|
||||
let (Some(up), Some(down)) = (nudged(1e-6), nudged(-1e-6)) else { continue };
|
||||
let pull: f64 = -up.iter().zip(&down).zip(&res).map(|((a, b), r)| (a - b) / 2e-6 * r).sum::<f64>();
|
||||
let eps = 1e-6 * up.iter().zip(&down).map(|(a, b)| ((a - b) / 2e-6).powi(2)).sum::<f64>().sqrt() * miss(&res);
|
||||
if at(hi) && pull > eps {
|
||||
out.limits.push((*id, *i, true));
|
||||
} else if at(lo) && pull < -eps {
|
||||
out.limits.push((*id, *i, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A loop stopped it when the chain alone gets closer.
|
||||
// ponytail: names the first loop of several; trying each one open would name the one that binds.
|
||||
if let Some(first) = closers.first() {
|
||||
let k = drive.len();
|
||||
let mut alone = vals.clone();
|
||||
let mut g = |x: &[f64]| misses(tree, asm, r, scale, &drive, &[], x, &mut alone, &spent);
|
||||
let (mut xo, bo, co) = (x0.get(..k).unwrap_or_default().to_vec(), bounds.get(..k).unwrap_or_default(), cap.get(..k).unwrap_or_default());
|
||||
// The loops left open, nothing to pace.
|
||||
if let Some(ro) = approach(&mut g, &|_, _| 0.0, mt, &mut xo, bo, co, tol)
|
||||
&& miss(&ro) < miss(&res) - REACH_TOL.max(1e-9 * miss(&res))
|
||||
{
|
||||
out.stopped_by_loop = Some(*first);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The stored values that changed, when that got it closer (by more than the solver's noise:
|
||||
// a held part doesn't creep).
|
||||
let gained = before - miss(&res) > 1e-7;
|
||||
for ((id, i), v) in vars.iter().zip(&x).filter(|_| gained) {
|
||||
let Some(j) = tree.joints.get(id) else { continue };
|
||||
if j.values.get(*i) == Some(v) {
|
||||
continue;
|
||||
}
|
||||
let q = out.values.entry(*id).or_insert_with(|| {
|
||||
let mut q = j.values.clone();
|
||||
q.resize(j.kind.dofs().len(), 0.0);
|
||||
q
|
||||
});
|
||||
if let Some(slot) = q.get_mut(*i) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The target's misses with the values `vars` at `x`: the point's (mm), then the axis's (as mm
|
||||
/// across the chain's `scale`), then the gaps of the loops `closers`, weighted.
|
||||
fn misses(
|
||||
tree: &Tree,
|
||||
asm: &Assembly,
|
||||
r: &Reach,
|
||||
scale: f64,
|
||||
vars: &[(u64, usize)],
|
||||
closers: &[u64],
|
||||
x: &[f64],
|
||||
work: &mut Values,
|
||||
spent: &Cell<usize>,
|
||||
) -> Option<Vec<f64>> {
|
||||
spent.set(spent.get() + 1);
|
||||
if spent.get() > MAX_LOOP_WORK {
|
||||
return None;
|
||||
}
|
||||
for ((id, i), v) in vars.iter().zip(x) {
|
||||
if let Some(slot) = work.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
apply_links(asm, work);
|
||||
let mut out = target(tree, r, scale, work)?;
|
||||
let n = out.len();
|
||||
closure_gaps(tree, closers, work, scale, &mut out)?;
|
||||
for v in out.iter_mut().skip(n) {
|
||||
*v *= LOOP_WEIGHT;
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// The target's misses for these values: the point's (mm), then the axis's (as mm across `scale`).
|
||||
fn target(tree: &Tree, r: &Reach, scale: f64, vals: &Values) -> Option<Vec<f64>> {
|
||||
let t = tree.world(r.occurrence, vals)?;
|
||||
let d = apply_point(&t, r.point) - r.to;
|
||||
let mut out = vec![d.x, d.y, d.z];
|
||||
if let Some((a, z)) = r.axis {
|
||||
let w = (apply_vector(&t, a) - z) * scale;
|
||||
out.extend([w.x, w.y, w.z]);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// How many steps apart the values `vars` at `a` and at `b` put the `paced` values (each with its
|
||||
/// step), motion links followed.
|
||||
fn stride(asm: &Assembly, vals: &Values, vars: &[(u64, usize)], paced: &[((u64, usize), f64)], a: &[f64], b: &[f64]) -> f64 {
|
||||
let at = |x: &[f64]| {
|
||||
let mut w = vals.clone();
|
||||
for ((id, i), v) in vars.iter().zip(x) {
|
||||
if let Some(slot) = w.get_mut(id).and_then(|q| q.get_mut(*i)) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
apply_links(asm, &mut w);
|
||||
w
|
||||
};
|
||||
let (wa, wb) = (at(a), at(b));
|
||||
let get = |w: &Values, id: &u64, i: usize| w.get(id).and_then(|q| q.get(i)).copied().unwrap_or(0.0);
|
||||
paced.iter().map(|((id, i), step)| (get(&wb, id, *i) - get(&wa, id, *i)).abs() / step).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Levenberg-Marquardt that stays on the loops: move `x` (within `bounds`, at most `cap` per value
|
||||
/// and a step of every value `stride` measures, links included, a step) to bring the first `mt` values of `f` (the target's misses) toward zero, the rest (the
|
||||
/// loops' weighted gaps) at zero. Each step is solved over all the rows, values on a limit the
|
||||
/// step pushes past held, then put back on the loops (`least_squares` on their gaps) and taken
|
||||
/// when it gets closer. It ends at the target, or as close as the limits and loops let it get.
|
||||
/// Returns `f` there, or `None` when the loops don't close at the start.
|
||||
fn approach(
|
||||
f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>,
|
||||
stride: &dyn Fn(&[f64], &[f64]) -> f64,
|
||||
mt: usize,
|
||||
x: &mut Vec<f64>,
|
||||
bounds: &[(f64, f64)],
|
||||
cap: &[f64],
|
||||
tol: f64,
|
||||
) -> Option<Vec<f64>> {
|
||||
let n = x.len();
|
||||
let miss = |r: &[f64]| r.iter().take(mt).map(|v| v * v).sum::<f64>();
|
||||
let close = |f: &mut dyn FnMut(&[f64]) -> Option<Vec<f64>>, x: &mut Vec<f64>| {
|
||||
least_squares(&mut |y| f(y).map(|r| r.get(mt..).unwrap_or_default().to_vec()), x, bounds, tol * LOOP_WEIGHT)
|
||||
};
|
||||
let loops = f(x)?.len() > mt;
|
||||
if loops && !close(f, x) {
|
||||
return None;
|
||||
}
|
||||
let mut r = f(x)?;
|
||||
let (mut lambda, mut kicked) = (1e-3, false);
|
||||
for _ in 0..MAX_REACH_STEPS {
|
||||
if largest(r.get(..mt).unwrap_or_default()) <= tol {
|
||||
break;
|
||||
}
|
||||
let m = r.len();
|
||||
let Some(jac) = jacobian(f, x, m) else { break };
|
||||
// The normal equations JᵀJ δ = −Jᵀr, and the target rows' share of the diagonal (the
|
||||
// damping scales with it, so the loops' weight doesn't slow the step along them).
|
||||
let (mut jtj, mut g, mut diag) = (linalg::Mat::zeros(n, n), vec![0.0; n], vec![0.0; n]);
|
||||
for (row, ri) in r.iter().enumerate() {
|
||||
let a = jac.get(row * n..(row + 1) * n).unwrap_or_default();
|
||||
for (p, ap) in a.iter().enumerate().filter(|(_, v)| **v != 0.0) {
|
||||
if let Some(gp) = g.get_mut(p) {
|
||||
*gp -= ap * ri;
|
||||
}
|
||||
if row < mt
|
||||
&& let Some(dp) = diag.get_mut(p)
|
||||
{
|
||||
*dp += ap * ap;
|
||||
}
|
||||
for (q, aq) in a.iter().enumerate().skip(p) {
|
||||
jtj.add(p, q, ap * aq);
|
||||
}
|
||||
}
|
||||
}
|
||||
for p in 0..n {
|
||||
for q in 0..p {
|
||||
jtj.set(p, q, jtj.get(q, p));
|
||||
}
|
||||
}
|
||||
let mut stepped = false;
|
||||
while lambda < 1e12 && !stepped {
|
||||
let mut held = vec![false; n];
|
||||
// Solved, then again with the values held that it pushes past their limits.
|
||||
for _ in 0..=n {
|
||||
let mut a = jtj.clone();
|
||||
let mut b = g.clone();
|
||||
for p in 0..n {
|
||||
a.add(p, p, lambda * (1.0 + diag.get(p).copied().unwrap_or(0.0)));
|
||||
if held.get(p).copied().unwrap_or(false) {
|
||||
for q in 0..n {
|
||||
a.set(p, q, if p == q { 1.0 } else { 0.0 });
|
||||
a.set(q, p, if p == q { 1.0 } else { 0.0 });
|
||||
}
|
||||
if let Some(bp) = b.get_mut(p) {
|
||||
*bp = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(d) = linalg::solve(a, b) else { break };
|
||||
let past: Vec<bool> =
|
||||
x.iter().zip(&d).zip(bounds).map(|((v, dv), (lo, hi))| (*dv > 0.0 && *v >= *hi) || (*dv < 0.0 && *v <= *lo)).collect();
|
||||
if past.iter().zip(&held).any(|(p, h)| *p && !*h) {
|
||||
held = held.iter().zip(&past).map(|(h, p)| *h || *p).collect();
|
||||
continue;
|
||||
}
|
||||
let full: Vec<f64> = x.iter().zip(&d).map(|(v, dv)| v + dv).collect();
|
||||
let over = d.iter().zip(cap).map(|(d, c)| d.abs() / c).fold(1.0f64, f64::max).max(stride(x, &full));
|
||||
let mut next: Vec<f64> = x.iter().zip(&d).zip(bounds).map(|((v, dv), (lo, hi))| (v + dv / over).max(*lo).min(*hi)).collect();
|
||||
// Checked again once taken: a link held at a limit or the loops closed again may
|
||||
// move it further than foreseen (joint.drive halves such steps too).
|
||||
if (!loops || close(f, &mut next))
|
||||
&& stride(x, &next) <= 2.0
|
||||
&& let Some(rn) = f(&next)
|
||||
&& miss(&rn) < miss(&r)
|
||||
{
|
||||
(*x, r) = (next, rn);
|
||||
// A step cut down to the cap (as at a stretched, singular pose) damps the next one
|
||||
// more instead of less, so it doesn't crawl along its longest value.
|
||||
lambda = if over > 1.0 { (lambda * over).min(1e6) } else { (lambda / 3.0).max(1e-9) };
|
||||
stepped = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if !stepped {
|
||||
lambda *= 4.0;
|
||||
}
|
||||
}
|
||||
if !stepped {
|
||||
// Where nothing changes the misses to first order but they aren't least (the target
|
||||
// straight across a pivot from the point), a small kick off it gets closer, once.
|
||||
if kicked {
|
||||
break;
|
||||
}
|
||||
kicked = true;
|
||||
// Both ways: one may run into a limit.
|
||||
for sign in [1.0, -1.0] {
|
||||
let mut next: Vec<f64> = x
|
||||
.iter()
|
||||
.zip(cap.iter().zip(bounds))
|
||||
.enumerate()
|
||||
.map(|(k, (v, (c, (lo, hi))))| (v + sign * 1e-3 * c * if k % 2 == 0 { 1.0 } else { -1.0 }).max(*lo).min(*hi))
|
||||
.collect();
|
||||
if (!loops || close(f, &mut next))
|
||||
&& stride(x, &next) <= 2.0
|
||||
&& let Some(rn) = f(&next)
|
||||
&& miss(&rn) < miss(&r)
|
||||
{
|
||||
(*x, r, lambda, stepped) = (next, rn, 1e-3, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !stepped {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(r)
|
||||
}
|
||||
@@ -34,10 +34,12 @@ pub mod metadata;
|
||||
pub use metadata::Metadata;
|
||||
pub mod motion;
|
||||
mod name_lookup;
|
||||
mod names;
|
||||
pub mod naming;
|
||||
mod params;
|
||||
pub mod physical_material;
|
||||
mod piece_names;
|
||||
mod points;
|
||||
mod profile_refs;
|
||||
pub mod progress;
|
||||
mod walk;
|
||||
@@ -56,11 +58,14 @@ pub use assembly::{
|
||||
pub use document::*;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SolvedSketch, ThreadInfo, curve_pick_error, edge_names_for,
|
||||
edge_refs, face_point_names, face_point_state, hole_direction_at, object_height, parse_metric_thread, pattern_matrices, revolve_extent,
|
||||
revolve_extent_with_convention, select_profiles, sketch_hole_direction, state_in_frame, state_in_frame_at, through_all_distance, world_state,
|
||||
world_state_reusing,
|
||||
edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, object_height, parse_metric_thread, pattern_matrices,
|
||||
revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane, state_in_frame, state_in_frame_at,
|
||||
through_all_distance, world_state, world_state_reusing,
|
||||
};
|
||||
pub use expr::Expr;
|
||||
pub use names::{BodyName, FeatureName, SketchId};
|
||||
pub use params::{ParamRow, ParamUser, cycles};
|
||||
pub use points::{Dir, FacePick, Position, plain};
|
||||
pub use solidcraft_kernel as kernel;
|
||||
pub use solidcraft_sketch as sketch;
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! What a feature refers to: bodies and features by name, sketches by id. Each is saved as the
|
||||
//! plain name or id it wraps, but has its own type, so a body name can't be passed where a
|
||||
//! feature name or an expression is meant.
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
macro_rules! name_type {
|
||||
($(#[$doc:meta])* $name:ident) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub String);
|
||||
|
||||
impl Deref for $name {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for $name {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::borrow::Borrow<str> for $name {
|
||||
fn borrow(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for $name {
|
||||
fn from(s: String) -> Self {
|
||||
$name(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for $name {
|
||||
fn from(s: &str) -> Self {
|
||||
$name(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for $name {
|
||||
fn from(s: &String) -> Self {
|
||||
$name(s.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for String {
|
||||
fn from(n: $name) -> String {
|
||||
n.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for $name {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for $name {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<String> for $name {
|
||||
fn eq(&self, other: &String) -> bool {
|
||||
&self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<$name> for String {
|
||||
fn eq(&self, other: &$name) -> bool {
|
||||
*self == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<$name> for str {
|
||||
fn eq(&self, other: &$name) -> bool {
|
||||
self == other.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
name_type!(
|
||||
/// A body, by name.
|
||||
BodyName
|
||||
);
|
||||
name_type!(
|
||||
/// A feature, by name.
|
||||
FeatureName
|
||||
);
|
||||
|
||||
/// A sketch, by its feature id.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct SketchId(pub u64);
|
||||
|
||||
impl Deref for SketchId {
|
||||
type Target = u64;
|
||||
fn deref(&self) -> &u64 {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for SketchId {
|
||||
fn from(id: u64) -> Self {
|
||||
SketchId(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SketchId> for u64 {
|
||||
fn from(id: SketchId) -> u64 {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<u64> for SketchId {
|
||||
fn eq(&self, other: &u64) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<SketchId> for u64 {
|
||||
fn eq(&self, other: &SketchId) -> bool {
|
||||
*self == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SketchId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
@@ -540,7 +540,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } => {
|
||||
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
|
||||
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
|
||||
let n = ss.plane.normal();
|
||||
// A face across the extrusion is a cap; others sweep a sketch curve.
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(n).len() < 1e-6);
|
||||
@@ -552,7 +552,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
nearest_curve(ss, Vec2::new(q.x, q.y)).map(|id| format!("F{fid}:side:{id}")).unwrap_or_else(|| format!("F{fid}:face:{fi}"))
|
||||
}
|
||||
FeatureKind::Revolve { sketch, axis, .. } => {
|
||||
let Some(ss) = before.sketch(*sketch) else { return format!("F{fid}:face:{fi}") };
|
||||
let Some(ss) = before.sketch(sketch.0) else { return format!("F{fid}:face:{fi}") };
|
||||
let Ok((o2, d2)) = crate::eval::revolve_axis(ss, axis) else { return format!("F{fid}:face:{fi}") };
|
||||
let (o, d) = (ss.plane.to_world(o2), (ss.plane.to_world(o2 + d2) - ss.plane.to_world(o2)).normalized().unwrap_or(Vec3::Z));
|
||||
// A flat face holding the axis is an angle cap: on the sketch plane, or the far one.
|
||||
@@ -582,7 +582,7 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
FeatureKind::Loft { sections, .. } => sections.first().map(|x| x.sketch),
|
||||
_ => None,
|
||||
};
|
||||
let Some(ss) = sk.and_then(|k| before.sketch(k)) else { return format!("F{fid}:face:{fi}") };
|
||||
let Some(ss) = sk.and_then(|k| before.sketch(k.0)) else { return format!("F{fid}:face:{fi}") };
|
||||
let n = ss.plane.normal();
|
||||
let h = |p: &Vec3| (*p - ss.plane.origin).dot(n);
|
||||
let flat = !ns.is_empty() && ns.windows(2).all(|w| w[0].cross(w[1]).len() < 1e-6);
|
||||
@@ -613,10 +613,10 @@ fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usi
|
||||
format!("F{fid}:{role}")
|
||||
}
|
||||
FeatureKind::Cylinder { axis, .. } => {
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(*axis).len() < 1e-6);
|
||||
let flat = !ns.is_empty() && ns.iter().all(|m| m.cross(axis.0).len() < 1e-6);
|
||||
let role = if !flat {
|
||||
"side"
|
||||
} else if ns.first().is_some_and(|m| m.dot(*axis) > 0.0) {
|
||||
} else if ns.first().is_some_and(|m| m.dot(axis.0) > 0.0) {
|
||||
"top"
|
||||
} else {
|
||||
"bottom"
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::expr::{self, Kind, ParamDef, Value};
|
||||
use crate::expr::{self, Expr, Kind, ParamDef, Value};
|
||||
use crate::walk::Walk;
|
||||
use crate::{DocError, Document, FeatureKind, MAX_PARAMS, Result};
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) enum ExprRole {
|
||||
|
||||
impl FeatureKind {
|
||||
/// The feature's numeric inputs: (label, expression, kind), in a fixed order.
|
||||
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut String, Kind)> {
|
||||
pub fn inputs_mut(&mut self) -> Vec<(&'static str, &mut Expr, Kind)> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs_mut(&mut |(label, e, kind, role)| {
|
||||
if role == ExprRole::Input {
|
||||
@@ -38,7 +38,31 @@ impl FeatureKind {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs(&mut |(label, e, kind, role)| {
|
||||
if role == ExprRole::Input {
|
||||
v.push((label, e.clone(), kind));
|
||||
v.push((label, e.to_string(), kind));
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// The coordinates of the points that place the feature: a primitive's position, an
|
||||
/// explicit plane's origin. They are not inputs (no parameter names), but follow
|
||||
/// parameters as inputs do.
|
||||
pub fn placement_exprs_mut(&mut self) -> Vec<&mut Expr> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs_mut(&mut |(_, e, _, role)| {
|
||||
if role == ExprRole::Placement {
|
||||
v.push(e);
|
||||
}
|
||||
});
|
||||
v
|
||||
}
|
||||
|
||||
/// Read-only view of [`FeatureKind::placement_exprs_mut`].
|
||||
pub fn placement_exprs(&self) -> Vec<&str> {
|
||||
let mut v = Vec::new();
|
||||
self.walk_exprs(&mut |(_, e, _, role)| {
|
||||
if role == ExprRole::Placement {
|
||||
v.push(e.as_str());
|
||||
}
|
||||
});
|
||||
v
|
||||
@@ -236,7 +260,7 @@ impl Document {
|
||||
if let Some(f) = next.features.get_mut(i)
|
||||
&& let Some((_, e, _)) = f.kind.inputs_mut().into_iter().nth(k)
|
||||
{
|
||||
*e = expr_s.clone();
|
||||
*e = expr_s.clone().into();
|
||||
}
|
||||
if let Some(c) = comment {
|
||||
next.param_comments.insert(name.to_string(), c.to_string());
|
||||
@@ -284,7 +308,7 @@ impl Document {
|
||||
if *target != normalized {
|
||||
changed.insert(name.clone());
|
||||
}
|
||||
*target = normalized;
|
||||
*target = normalized.into();
|
||||
}
|
||||
let (_, errors) = next.param_values();
|
||||
for (name, error) in errors {
|
||||
@@ -352,6 +376,25 @@ impl Document {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Move user parameter `name` to just before (or with `after`, just after) user parameter
|
||||
/// `target`: the order the Parameters dialog lists them in. Nothing else changes.
|
||||
pub fn move_user_param(&mut self, name: &str, target: &str, after: bool) -> Result<()> {
|
||||
fn user(params: &[crate::Param], n: &str) -> Option<usize> {
|
||||
params.iter().position(|p| p.name == n && !p.model)
|
||||
}
|
||||
let (Some(from), Some(_)) = (user(&self.params, name), user(&self.params, target)) else {
|
||||
let missing = if user(&self.params, name).is_none() { name } else { target };
|
||||
return Err(DocError::Unknown(format!("user parameter `{missing}`")));
|
||||
};
|
||||
if name == target {
|
||||
return Err(DocError::Invalid("a parameter cannot move next to itself".into()));
|
||||
}
|
||||
let moved = self.params.remove(from);
|
||||
let at = user(&self.params, target).map_or(self.params.len(), |i| if after { i + 1 } else { i });
|
||||
self.params.insert(at, moved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
|
||||
if !self.name_taken(name) {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
@@ -556,7 +599,7 @@ impl Document {
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.kind.point_expr().is_some_and(|p| p.iter().any(|e| expr::references(e).iter().any(|r| r == name))) {
|
||||
if f.kind.placement_exprs().iter().any(|e| expr::references(e).iter().any(|r| r == name)) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: "Position".into() });
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
@@ -565,6 +608,11 @@ impl Document {
|
||||
out.push(ParamUser::Dimension { feature: f.id, name: f.name.clone(), constraint: c.id.clone() });
|
||||
}
|
||||
}
|
||||
for (what, e) in sketch.text_exprs() {
|
||||
if expr::references(e).iter().any(|r| r == name) {
|
||||
out.push(ParamUser::Feature { feature: f.id, name: f.name.clone(), input: what.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -622,17 +670,17 @@ impl Document {
|
||||
*x = new.to_string();
|
||||
}
|
||||
}
|
||||
for e in f.kind.point_expr_mut().into_iter().flatten() {
|
||||
for e in f.kind.placement_exprs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
*e = r.into();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for (_, e, _) in f.kind.inputs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
*e = r.into();
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -643,6 +691,13 @@ impl Document {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
for e in sketch.text_exprs_mut() {
|
||||
let r = expr::rename_reference(e, old, new);
|
||||
if r != *e {
|
||||
*e = r;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.favorites.remove(old) {
|
||||
@@ -698,6 +753,7 @@ impl Document {
|
||||
let mut names: BTreeSet<String> = f.param_names.iter().filter(|n| !n.is_empty()).cloned().collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
names.extend(sketch.text_exprs().into_iter().flat_map(|(_, e)| expr::references(e)));
|
||||
}
|
||||
out.extend(names.into_iter().map(|n| (format!("Feature:{}", f.name), n)));
|
||||
}
|
||||
@@ -725,6 +781,7 @@ impl Document {
|
||||
hit |= f.kind.expressions().iter().any(|e| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
hit |= sketch.constraints.iter().any(|c| c.param.as_ref().is_some_and(|p| affected.contains(p)));
|
||||
hit |= sketch.text_exprs().iter().any(|(_, e)| expr::references(e).iter().any(|r| affected.contains(r)));
|
||||
}
|
||||
if hit {
|
||||
out.push(f.id);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{DocError, Result};
|
||||
|
||||
@@ -116,6 +116,10 @@ pub struct Validation {
|
||||
pub property_kinds: Vec<String>,
|
||||
pub isotropic_moduli: Option<IsotropicModuli>,
|
||||
pub unspecified_conditions: Vec<String>,
|
||||
/// Property kind -> absent condition fields under the existing family policy.
|
||||
/// Empty when reading an older report that did not include this detail.
|
||||
#[serde(default)]
|
||||
pub missing_condition_fields: BTreeMap<String, Vec<String>>,
|
||||
pub source_review_required: Vec<String>,
|
||||
pub analysis_solver_available: bool,
|
||||
pub rendering_properties_available: bool,
|
||||
@@ -256,6 +260,7 @@ impl MaterialCard {
|
||||
let mut kinds = BTreeSet::new();
|
||||
let mut used_sources = BTreeSet::new();
|
||||
let mut unspecified = Vec::new();
|
||||
let mut missing_condition_fields = BTreeMap::new();
|
||||
let mut isotropic_moduli = None;
|
||||
for property in &self.properties {
|
||||
let kind = property.data.kind();
|
||||
@@ -279,12 +284,20 @@ impl MaterialCard {
|
||||
if let Some(state) = &conditions.state {
|
||||
text(state, 1024, "conditions.state")?;
|
||||
}
|
||||
if conditions.temperature_k.is_none()
|
||||
|| conditions.state.is_none()
|
||||
|| (self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none())
|
||||
|| (self.family == Family::Polymer && conditions.strain_rate_per_s.is_none())
|
||||
{
|
||||
let mut missing = Vec::new();
|
||||
for (absent, field) in [
|
||||
(conditions.temperature_k.is_none(), "temperature_k"),
|
||||
(conditions.state.is_none(), "state"),
|
||||
(self.family == Family::Wood && conditions.moisture_dry_basis_kg_kg.is_none(), "moisture_dry_basis_kg_kg"),
|
||||
(self.family == Family::Polymer && conditions.strain_rate_per_s.is_none(), "strain_rate_per_s"),
|
||||
] {
|
||||
if absent {
|
||||
missing.push(field.to_string());
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
unspecified.push(kind.to_string());
|
||||
missing_condition_fields.insert(kind.to_string(), missing);
|
||||
}
|
||||
if let Some(moduli) = property.data.validate()? {
|
||||
isotropic_moduli = Some(moduli);
|
||||
@@ -295,6 +308,7 @@ impl MaterialCard {
|
||||
property_kinds: kinds.into_iter().collect(),
|
||||
isotropic_moduli,
|
||||
unspecified_conditions: unspecified,
|
||||
missing_condition_fields,
|
||||
source_review_required: used_sources.into_iter().collect(),
|
||||
analysis_solver_available: false,
|
||||
rendering_properties_available: false,
|
||||
|
||||
@@ -132,6 +132,58 @@ fn conditions_missing_values_and_source_claims_remain_explicit() {
|
||||
assert_eq!(serde_json::from_value::<MaterialCard>(serde_json::to_value(&parsed).unwrap()).unwrap(), parsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_condition_fields_follow_family_policy_without_inventing_values() {
|
||||
for (family, extra) in
|
||||
[("wood", Some("moisture_dry_basis_kg_kg")), ("polymer", Some("strain_rate_per_s")), ("glass", None), ("metal", None), ("other", None)]
|
||||
{
|
||||
let mut value = card(json!({"kind":"density","kg_m3":600.}));
|
||||
value["family"] = json!(family);
|
||||
let mut expected = vec!["temperature_k", "state"];
|
||||
expected.extend(extra);
|
||||
let result = validate_card(&value).unwrap();
|
||||
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), expected.iter().map(|s| (*s).into()).collect())]));
|
||||
assert_eq!(result.unspecified_conditions, vec!["density"]);
|
||||
|
||||
// Explicit zero dry-basis moisture is known; no wet/dry conversion or default is inferred.
|
||||
value["properties"][0]["conditions"] =
|
||||
json!({"temperature_k":293.15,"state":"synthetic","moisture_dry_basis_kg_kg":0.,"strain_rate_per_s":1.});
|
||||
assert!(validate_card(&value).unwrap().missing_condition_fields.is_empty());
|
||||
for field in expected {
|
||||
let mut partial = value.clone();
|
||||
partial["properties"][0]["conditions"].as_object_mut().unwrap().remove(field);
|
||||
let result = validate_card(&partial).unwrap();
|
||||
assert_eq!(result.missing_condition_fields, BTreeMap::from([("density".into(), vec![field.into()])]));
|
||||
assert_eq!(result.unspecified_conditions, vec!["density"]);
|
||||
}
|
||||
value["properties"] = json!([]);
|
||||
let result = validate_card(&value).unwrap();
|
||||
assert!(result.missing_condition_fields.is_empty() && result.unspecified_conditions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_condition_fields_are_per_property_and_reports_remain_backward_readable() {
|
||||
let mut value = card(json!({"kind":"density","kg_m3":600.}));
|
||||
value["properties"][0]["conditions"] = json!({"temperature_k":293.15,"state":"synthetic"});
|
||||
value["properties"].as_array_mut().unwrap().push(json!({"data":isotropic(12e9,0.25),"source_id":"test",
|
||||
"conditions":{"state":"synthetic","moisture_dry_basis_kg_kg":1.5}}));
|
||||
let before = value.clone();
|
||||
let result = validate_card(&value).unwrap();
|
||||
let expected = json!({"density":["moisture_dry_basis_kg_kg"],"elasticity":["temperature_k"]});
|
||||
let serialized = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(serialized["missing_condition_fields"], expected);
|
||||
assert_eq!(result.unspecified_conditions, vec!["density", "elasticity"]);
|
||||
assert_eq!(value, before);
|
||||
assert_eq!(serde_json::from_value::<Validation>(serialized.clone()).unwrap(), result);
|
||||
let mut old = serialized;
|
||||
old.as_object_mut().unwrap().remove("missing_condition_fields");
|
||||
let old: Validation = serde_json::from_value(old).unwrap();
|
||||
assert!(old.missing_condition_fields.is_empty());
|
||||
assert_eq!(old.unspecified_conditions, result.unspecified_conditions);
|
||||
assert_eq!(old.source_review_required, result.source_review_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thermal_properties_accept_negative_expansion_but_not_negative_conductivity() {
|
||||
assert!(validate_card(&card(json!({"kind":"thermal_expansion","per_k":[-1e-6,0.,2e-6],"axes":"material123"}))).is_ok());
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The points a feature holds, by what they are: a point that picks a face, any other
|
||||
//! position, a direction. Each is a `Vec3` (and is saved as one), but a different type, so a
|
||||
//! direction can't be moved like a position, and a pick can't be mistaken for either.
|
||||
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
macro_rules! point_type {
|
||||
($(#[$doc:meta])* $name:ident) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub Vec3);
|
||||
|
||||
impl Deref for $name {
|
||||
type Target = Vec3;
|
||||
fn deref(&self) -> &Vec3 {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for $name {
|
||||
fn deref_mut(&mut self) -> &mut Vec3 {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Vec3> for $name {
|
||||
fn from(v: Vec3) -> Self {
|
||||
$name(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for Vec3 {
|
||||
fn from(p: $name) -> Vec3 {
|
||||
p.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
point_type!(
|
||||
/// A point that picks a face: it moves with the feature, and names the face it picks.
|
||||
FacePick
|
||||
);
|
||||
point_type!(
|
||||
/// A position (an edge pick, an axis origin, a fold line): it moves with the feature.
|
||||
Position
|
||||
);
|
||||
point_type!(
|
||||
/// A direction: it turns with the feature, and never moves.
|
||||
Dir
|
||||
);
|
||||
|
||||
/// The plain values of a list of picks, positions, directions or names (for code that takes
|
||||
/// `Vec3`s or `String`s).
|
||||
pub fn plain<T, P: Clone + Into<T>>(ps: &[P]) -> Vec<T> {
|
||||
ps.iter().map(|p| p.clone().into()).collect()
|
||||
}
|
||||
@@ -309,7 +309,7 @@ mod tests {
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: feature,
|
||||
sketch: crate::SketchId(feature),
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
@@ -436,7 +436,7 @@ mod tests {
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: feature,
|
||||
sketch: crate::SketchId(feature),
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
|
||||
@@ -255,14 +255,17 @@ fn resolve_uncached(
|
||||
LinkSource::SketchAxis { name } => {
|
||||
vec![solidcraft_sketch::sketch_axis_geom(name).ok_or_else(|| DocError::Unknown(format!("sketch axis `{name}`")))?]
|
||||
}
|
||||
LinkSource::Text { text, at, height, angle, font, style } if style.path.is_none() => {
|
||||
LinkSource::Text { text, at, height, angle, font, style, .. } if style.path.is_none() => {
|
||||
solidcraft_sketch::text_geometry_styled(font.as_deref(), text, *at, *height, *angle, style, None)?
|
||||
}
|
||||
// Its path is a curve of its own sketch: set with the sketch (`Sketch::text_source_geometry`).
|
||||
LinkSource::Text { .. } => return Err(DocError::Invalid("a text on a path is set with its sketch".into())),
|
||||
LinkSource::Plane { name } => {
|
||||
let pr =
|
||||
if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } };
|
||||
let pr = if Plane::named(name).is_some() {
|
||||
PlaneRef::Origin { name: name.clone() }
|
||||
} else {
|
||||
PlaneRef::Construction { name: name.clone().into() }
|
||||
};
|
||||
let other = doc.resolve_plane_in(vals, &pr, 0, Some(st)).map_err(|_| lost(&format!("plane {name}")))?;
|
||||
plane_trace(plane, &other).into_iter().collect()
|
||||
}
|
||||
|
||||
@@ -67,13 +67,18 @@ impl Default for RenderSetup {
|
||||
impl RenderSetup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let limits = RenderLimits::default();
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| u64::from(self.width) > limits.max_dimension
|
||||
|| u64::from(self.height) > limits.max_dimension
|
||||
|| u64::from(self.width) * u64::from(self.height) > limits.max_pixels
|
||||
let (width, height) = (u64::from(self.width), u64::from(self.height));
|
||||
if width == 0 || height == 0 {
|
||||
return Err(DocError::Invalid("render output must be at least 1 × 1 pixels".into()));
|
||||
}
|
||||
if let Some((w, h)) = limits.fit(width, height, None)
|
||||
&& (w, h) != (width, height)
|
||||
{
|
||||
return Err(DocError::Invalid("render output exceeds the dimension/pixel limits".into()));
|
||||
return Err(DocError::Invalid(format!(
|
||||
"render output {width} × {height} exceeds the image limits ({} pixels per side, {} pixels in all); \
|
||||
the largest image at this aspect is {w} × {h}",
|
||||
limits.max_dimension, limits.max_pixels
|
||||
)));
|
||||
}
|
||||
let camera = &self.camera;
|
||||
if !camera.is_valid()
|
||||
@@ -218,6 +223,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_output_names_the_limits_and_the_largest_size() {
|
||||
let setup = RenderSetup { width: 4096, height: 4096, ..Default::default() };
|
||||
let message = setup.validate().unwrap_err().to_string();
|
||||
assert!(message.contains("4096 × 4096 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
|
||||
assert!(message.contains("the largest image at this aspect is 2048 × 2048"), "{message}");
|
||||
let setup = RenderSetup { width: 7680, height: 4320, ..Default::default() };
|
||||
let message = setup.validate().unwrap_err().to_string();
|
||||
assert!(message.contains("the largest image at this aspect is 27"), "{message}");
|
||||
assert!(RenderSetup { width: 2048, height: 2048, ..Default::default() }.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_saved_intent_is_rejected_at_deserialization() {
|
||||
for patch in [
|
||||
|
||||
+273
-28
@@ -31,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
|
||||
const MAX_FLAT_SAMPLES: usize = 65_536;
|
||||
const MAX_FLAT_PIECES: usize = 4096;
|
||||
const MAX_FLAT_WORK: usize = 500_000_000;
|
||||
/// Pieces of flat material sharing less area than this (mm²) only touch.
|
||||
const TOUCH_AREA: f64 = 1e-6;
|
||||
|
||||
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
|
||||
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
|
||||
@@ -359,6 +361,15 @@ impl ReliefShape {
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner relief an edge flange feature asks for in place of the rule's. A value left out
|
||||
/// follows the rule.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct ReliefOverride {
|
||||
pub shape: Option<ReliefShape>,
|
||||
/// How far across the relief is (mm).
|
||||
pub size: Option<f64>,
|
||||
}
|
||||
|
||||
/// A rule's values (mm, unit-less K).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct RuleValues {
|
||||
@@ -490,6 +501,12 @@ pub struct SheetBody {
|
||||
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub perforations: Vec<SheetPerforation>,
|
||||
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
|
||||
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
|
||||
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
|
||||
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub labels: Vec<Region2>,
|
||||
/// Unfolded.
|
||||
pub flat: bool,
|
||||
/// Gap between flanges that meet at a corner.
|
||||
@@ -1600,6 +1617,26 @@ impl SheetBody {
|
||||
Ok(through.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
|
||||
/// loop of the flat pattern is a letter.
|
||||
pub fn letters(&self) -> Letters {
|
||||
let regions = self
|
||||
.labels
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in r.outer.polyline(1e-3) {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
|
||||
let pad = Vec2::new(0.01, 0.01);
|
||||
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
|
||||
})
|
||||
.collect();
|
||||
Letters { regions }
|
||||
}
|
||||
|
||||
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
|
||||
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
|
||||
let material = self.flat_regions()?;
|
||||
@@ -1670,6 +1707,84 @@ impl SheetBody {
|
||||
let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? };
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
/// The pieces of flat material that a cut parted from the base: `after` is this sheet's
|
||||
/// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when
|
||||
/// it holds none of the base panel, lies in a piece that held some before the cut, and no
|
||||
/// other piece surrounds it: a flange cut off along its bend (with any flange on it), the
|
||||
/// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut
|
||||
/// through a closed corner. A ring's island and a letter's counter, inside other material,
|
||||
/// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds
|
||||
/// the base.
|
||||
pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result<Vec<Region2>> {
|
||||
if after.len() < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
work.charge(after.len().saturating_mul(after.len()))?;
|
||||
let base = [planar_region(&self.base)?];
|
||||
let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result<bool> {
|
||||
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
|
||||
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
|
||||
};
|
||||
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
|
||||
let outlines: Vec<(Vec<Vec2>, Vec2, Vec2)> = after
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let pts = r.outer.polyline(1e-3);
|
||||
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
|
||||
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
|
||||
(pts, lo, hi)
|
||||
})
|
||||
.collect();
|
||||
let (mut open, mut islands) = (Vec::new(), Vec::new());
|
||||
for (i, piece) in after.iter().enumerate() {
|
||||
let p = piece.interior_point();
|
||||
let inside = |(pts, lo, hi): &(Vec<Vec2>, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p);
|
||||
if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) {
|
||||
islands.push(piece);
|
||||
} else {
|
||||
open.push(piece);
|
||||
}
|
||||
}
|
||||
let (mut joined, mut loose) = (false, Vec::new());
|
||||
for piece in open {
|
||||
if holds_base(piece, &mut work)? {
|
||||
joined = true;
|
||||
} else {
|
||||
loose.push(piece);
|
||||
}
|
||||
}
|
||||
if loose.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// The base may be an island itself (a cut all round it).
|
||||
for piece in islands {
|
||||
if joined {
|
||||
break;
|
||||
}
|
||||
joined = holds_base(piece, &mut work)?;
|
||||
}
|
||||
if !joined {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// A cut only takes material away: each piece lies in the piece it was part of.
|
||||
let mut held = Vec::new();
|
||||
for piece in before.flat_material()? {
|
||||
if holds_base(&piece, &mut work)? {
|
||||
held.push(piece);
|
||||
}
|
||||
}
|
||||
Ok(loose
|
||||
.into_iter()
|
||||
.filter(|piece| {
|
||||
let p = piece.interior_point();
|
||||
held.iter().any(|h| h.contains(p))
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
|
||||
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
|
||||
@@ -2045,7 +2160,7 @@ impl SheetBody {
|
||||
flip: bool,
|
||||
) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
let each: Vec<EdgeFlange> = picks.iter().map(|&pick| EdgeFlange { pick, height, angle }).collect();
|
||||
self.add_flanges_each(&each, radius, position, flip)
|
||||
self.add_flanges_each(&each, radius, position, flip, ReliefOverride::default())
|
||||
}
|
||||
|
||||
/// Edge flanges that each have their own height and angle, in one go: every edge is trimmed
|
||||
@@ -2058,8 +2173,16 @@ impl SheetBody {
|
||||
/// the base that ends beside more of the base gets the rule's bend relief there
|
||||
/// (`relieve_bend_ends`). Where cut-outs have taken part of a picked edge away, the flange
|
||||
/// runs along the stretch they leave nearest the pick and takes its setback off that stretch
|
||||
/// only; an edge they took whole is refused.
|
||||
pub fn add_flanges_each(&mut self, edges: &[EdgeFlange], radius: f64, position: &str, flip: bool) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
/// only; an edge they took whole is refused. The corners between these flanges are relieved
|
||||
/// as the rule says, except where `relief` names another shape or size.
|
||||
pub fn add_flanges_each(
|
||||
&mut self,
|
||||
edges: &[EdgeFlange],
|
||||
radius: f64,
|
||||
position: &str,
|
||||
flip: bool,
|
||||
relief: ReliefOverride,
|
||||
) -> Result<(Vec<usize>, Vec<String>)> {
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(DocError::Invalid("pick 1…1000 edges".into()));
|
||||
}
|
||||
@@ -2187,7 +2310,7 @@ impl SheetBody {
|
||||
for (&i, &free) in made.iter().zip(&freed) {
|
||||
notes.extend(self.relieve_bend_ends(i, free));
|
||||
}
|
||||
notes.extend(self.close_corners(&made));
|
||||
notes.extend(self.close_corners(&made, relief));
|
||||
notes.extend(self.close_return_corners(&made));
|
||||
Ok((made, notes))
|
||||
}
|
||||
@@ -2201,7 +2324,7 @@ impl SheetBody {
|
||||
/// such end left as it is.
|
||||
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
|
||||
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
|
||||
if f.parent.is_some() || f.shape.is_some() {
|
||||
if f.parent.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
|
||||
@@ -2605,37 +2728,50 @@ impl SheetBody {
|
||||
out
|
||||
}
|
||||
|
||||
/// Close the corners the new flanges make, then cut the rule's relief at each (or tear it).
|
||||
/// Returns a note for every corner left open, without its relief or not torn.
|
||||
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
|
||||
/// Close the corners the new flanges make, then cut the relief at each (or tear it): the
|
||||
/// rule's, but for the shape or size `relief` names. Returns a note for every corner left
|
||||
/// open, without its relief or not torn.
|
||||
fn close_corners(&mut self, made: &[usize], relief: ReliefOverride) -> Vec<String> {
|
||||
let corners = self.corners(made);
|
||||
let mut notes = Vec::new();
|
||||
let at = |s: &SheetBody, c: Vec2| {
|
||||
let w = apply_point(&s.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
};
|
||||
let mut closed = Vec::with_capacity(corners.len());
|
||||
for k in &corners {
|
||||
match self.close_corner(k) {
|
||||
Ok(()) => closed.push(*k),
|
||||
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
|
||||
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
|
||||
}
|
||||
}
|
||||
match self.relief {
|
||||
notes.extend(self.relieve_corners(&corners, &closed, relief));
|
||||
notes
|
||||
}
|
||||
|
||||
/// A point of the flat frame, in the part's coordinates, for a note.
|
||||
fn shown(&self, c: Vec2) -> String {
|
||||
let w = apply_point(&self.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
}
|
||||
|
||||
/// Cut the relief at each of `corners`, or tear those of them that are `closed`: the rule's,
|
||||
/// but for the shape or size `relief` names. Returns a note for every corner without its
|
||||
/// relief or not torn.
|
||||
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner], relief: ReliefOverride) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
let (shape, size) = (relief.shape.unwrap_or(self.relief), relief.size.unwrap_or(self.relief_size));
|
||||
match shape {
|
||||
ReliefShape::Notch => {}
|
||||
// The reliefs come last: they move the bends' ends, which is how the corners were found.
|
||||
ReliefShape::Round | ReliefShape::Rectangular => {
|
||||
for k in &corners {
|
||||
if let Err(why) = self.relieve_corner(k, &corners) {
|
||||
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
|
||||
for k in corners {
|
||||
if let Err(why) = self.relieve_corner(k, corners, shape, size) {
|
||||
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
|
||||
ReliefShape::Tear => {
|
||||
for k in &closed {
|
||||
for k in closed {
|
||||
if let Err(why) = self.tear_corner(k) {
|
||||
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
|
||||
notes.push(format!("the corner at {} keeps its notch: {why}", self.shown(k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2802,7 +2938,7 @@ impl SheetBody {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cut the rule's relief where two bends meet at a corner of the base, `relief_size / 2`
|
||||
/// Cut a relief `size` across where two bends meet at a corner of the base, `size / 2`
|
||||
/// from the corner along both edges: a round about the corner through the angle the base
|
||||
/// fills there, or the square with that side (a kite where the corner isn't square, its
|
||||
/// cuts square to the two edges). The base's edges then start that far from the corner, so
|
||||
@@ -2812,8 +2948,7 @@ impl SheetBody {
|
||||
/// that would leave nothing of an edge it starts on (with the relief at that edge's other
|
||||
/// end, where there is one), or one reaching as far from the corner as another edge of the
|
||||
/// base is, is refused with the reason.
|
||||
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner]) -> std::result::Result<(), String> {
|
||||
let size = self.relief_size;
|
||||
fn relieve_corner(&mut self, k: &Corner, corners: &[Corner], shape: ReliefShape, size: f64) -> std::result::Result<(), String> {
|
||||
if !(size >= MIN_CORNER_RELIEF) {
|
||||
return Err(format!("the relief size {size:.4} mm is below {MIN_CORNER_RELIEF} mm"));
|
||||
}
|
||||
@@ -2845,7 +2980,7 @@ impl SheetBody {
|
||||
}
|
||||
// The relief from the edge that ends at the corner to the one that starts there, and
|
||||
// how far from the corner it reaches.
|
||||
let (relief, reach) = match self.relief {
|
||||
let (relief, reach) = match shape {
|
||||
ReliefShape::Notch | ReliefShape::Tear => return Ok(()),
|
||||
ReliefShape::Round => (vec![Seg2::Arc { center: c, radius: r, start: da.angle(), sweep: -alpha }], r),
|
||||
ReliefShape::Rectangular => {
|
||||
@@ -3953,6 +4088,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
|
||||
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
|
||||
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
|
||||
const TOL: f64 = 1e-4;
|
||||
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
|
||||
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
|
||||
}
|
||||
|
||||
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Letters {
|
||||
/// Each text region's corners (lowest, highest) and the segments of its outlines.
|
||||
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl Letters {
|
||||
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
|
||||
/// counters.
|
||||
pub fn is_letter(&self, lp: &Loop2) -> bool {
|
||||
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
|
||||
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
|
||||
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
|
||||
let (a, b) = (planar_region(a)?, planar_region(b)?);
|
||||
@@ -3974,6 +4133,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
|
||||
pockets: Vec::new(),
|
||||
bores: Vec::new(),
|
||||
perforations: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
@@ -4031,6 +4191,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
pockets: Vec::new(),
|
||||
bores: Vec::new(),
|
||||
perforations: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
@@ -4096,7 +4257,9 @@ pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str
|
||||
*name = renamed;
|
||||
}
|
||||
}
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => rename_plane_sheet_body(base, old, new),
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } | crate::PlaneRef::Flipped { base, .. } => {
|
||||
rename_plane_sheet_body(base, old, new)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -4106,7 +4269,9 @@ pub fn rename_plane_sheet_body(plane: &mut crate::PlaneRef, old: &str, new: &str
|
||||
pub fn plane_sheet_face(plane: &crate::PlaneRef) -> Option<&str> {
|
||||
match plane {
|
||||
crate::PlaneRef::Face { name: Some(name), anchor: None, .. } if split_sheet_face_name(name).is_some() => Some(name),
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } => plane_sheet_face(base),
|
||||
crate::PlaneRef::Offset { base, .. } | crate::PlaneRef::AtAngle { base, .. } | crate::PlaneRef::Flipped { base, .. } => {
|
||||
plane_sheet_face(base)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -4175,6 +4340,41 @@ mod tests {
|
||||
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_lie_on_the_curves_they_are_made_of() {
|
||||
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
|
||||
let curves: Vec<Seg2> = circle.segs.clone();
|
||||
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
|
||||
assert!(loop_lies_on(&circle, &curves));
|
||||
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
|
||||
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
|
||||
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
|
||||
// Another circle, a square and an empty loop do not.
|
||||
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
|
||||
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
|
||||
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
|
||||
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
|
||||
// A loop only partly on them (a letter across the sheet's edge) is not on them.
|
||||
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
|
||||
assert!(!loop_lies_on(¬ch, &curves));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_knows_the_letters_it_carries() {
|
||||
// A recorded text region with a counter: its outline and its counter are letters, the same
|
||||
// circle elsewhere, a loop near it that is another size and a loop far away are not.
|
||||
let mut s = plate(100.0, 60.0);
|
||||
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
|
||||
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
|
||||
let letters = s.letters();
|
||||
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
|
||||
assert!(letters.is_letter(&counter.reversed()));
|
||||
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
|
||||
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
|
||||
// A sheet that carries none has no letters.
|
||||
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flange_matches_fusion() {
|
||||
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
|
||||
@@ -5898,7 +6098,7 @@ mod tests {
|
||||
/// sheet and the notes on corners left open.
|
||||
fn flanges_each(base: Region2, rv: &RuleValues, each: &[EdgeFlange]) -> (SheetBody, Vec<String>) {
|
||||
let mut s = base_flange("Body1", "AL2", rv, Plane::XY, base);
|
||||
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false).unwrap().1;
|
||||
let notes = s.add_flanges_each(each, rv.bend_radius, "inside", false, ReliefOverride::default()).unwrap().1;
|
||||
(s, notes)
|
||||
}
|
||||
|
||||
@@ -6453,7 +6653,7 @@ mod tests {
|
||||
let (mut s, _) = tray(&rv, 90.0, 20.0);
|
||||
let each: Vec<EdgeFlange> =
|
||||
(0..4).map(|i| EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: [90f64, 70.0][i % 2].to_radians() }).collect();
|
||||
let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap();
|
||||
let (made, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap();
|
||||
assert!(notes.is_empty() && made == [4, 5, 6, 7], "{made:?} {notes:?}");
|
||||
for (a, a_end, b, b_end) in TRAY_RETURNS {
|
||||
let (fa, fb) = (&s.flanges[a], &s.flanges[b]);
|
||||
@@ -6626,7 +6826,7 @@ mod tests {
|
||||
EdgeFlange { pick: far_edge_pick(&s, i, true), height: 12.0, angle: deg.to_radians() }
|
||||
})
|
||||
.collect();
|
||||
let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false).unwrap();
|
||||
let (_, notes) = s.add_flanges_each(&each, 1.0, "inside", false, ReliefOverride::default()).unwrap();
|
||||
assert!(notes.is_empty(), "{what}: {notes:?}");
|
||||
returns_keep_out_of_the_walls(&s, &what);
|
||||
holds_the_flat_material(&s, &what);
|
||||
@@ -7292,4 +7492,49 @@ mod tests {
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the
|
||||
/// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the
|
||||
/// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15.
|
||||
#[test]
|
||||
fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0;
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1));
|
||||
let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] };
|
||||
let parted = |before: &SheetBody, holes: &[Region2]| {
|
||||
let mut after = before.clone();
|
||||
after.holes.extend_from_slice(holes);
|
||||
after.parted_from_base(&after.flat_material().unwrap(), before).unwrap()
|
||||
};
|
||||
// A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far).
|
||||
let slot = r(-5.0, -3.0, 105.0, -1.0);
|
||||
let p = parted(&s, std::slice::from_ref(&slot));
|
||||
assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}");
|
||||
// So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2.
|
||||
let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]);
|
||||
assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}");
|
||||
// A ring's island, on the base or on the flange, has material round it; a strip across the
|
||||
// whole sheet leaves two halves that each keep their part of the base. None is named.
|
||||
for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] {
|
||||
assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
|
||||
}
|
||||
// Pieces an earlier cut parted are not named again, nor when a later cut splits them.
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(slot);
|
||||
for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] {
|
||||
assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
|
||||
}
|
||||
// Nothing is named once no piece holds the base: a cut that takes the base and both bends
|
||||
// of a sheet with flanges on two sides leaves the two flanges.
|
||||
let mut two = plate(100.0, 60.0);
|
||||
two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
let mut after = two.clone();
|
||||
after.holes.push(r(-5.0, -3.0, 105.0, 63.0));
|
||||
let material = after.flat_material().unwrap();
|
||||
assert_eq!(material.len(), 2);
|
||||
assert!(after.parted_from_base(&material, &two).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@
|
||||
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
|
||||
//! gap from the plane halfway between the two.
|
||||
//!
|
||||
//! The bends on the fixed face are finished as the flanges of one edge flange feature are: where
|
||||
//! two meet at a corner of it, their walls are closed with the rule's gap and the corner gets the
|
||||
//! rule's corner relief (round, rectangular, a tear, or the notch the bends leave), and where one
|
||||
//! ends beside more of the fixed face, the rule's bend relief is cut there. A wall that stands on
|
||||
//! part of an edge of the fixed face (its outside in one plane with the plate's edge, which runs
|
||||
//! on past it a strip no taller than the bend's setback) bends only where it stands: the strip is
|
||||
//! the fixed face's own edge.
|
||||
//!
|
||||
//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii
|
||||
//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet:
|
||||
//! the faces on either side are taken out to where their planes meet, and that line is the
|
||||
@@ -37,7 +45,7 @@
|
||||
//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach
|
||||
//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round
|
||||
//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than
|
||||
//! one edge.
|
||||
//! one edge, a wall on part of an edge of a face other than the fixed one.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
@@ -45,7 +53,7 @@ use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface, face_surface};
|
||||
|
||||
use super::{FlatWork, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
|
||||
use super::{FlatWork, ReliefOverride, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
|
||||
use crate::{DocError, ModelBody, Result, apply_point, mat_inverse};
|
||||
|
||||
const MAX_BENDS: usize = 1000;
|
||||
@@ -500,6 +508,96 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
|
||||
Ok((out, from))
|
||||
}
|
||||
|
||||
/// A wall standing on part of an edge of the fixed face: where the outline of `wall`, the face
|
||||
/// across a bend from the fixed face `base` (`ids`: the two faces), leaves the bend's edge up a
|
||||
/// strip no taller than the bend's setback `by`, runs back along the edge and climbs the wall's
|
||||
/// end, the strip is the fixed face's own edge (a plate's edge face in one plane with the outside
|
||||
/// of a wall that stands on part of it), which the setback would use up. It is cut off the wall,
|
||||
/// whose bend's edge then ends where the line of the wall's end meets it, and the fixed face's
|
||||
/// edge is split there: the pieces beside the wall are no bend. `bend` says whether a face is a
|
||||
/// bend of the wall. Any other wall is left as it is.
|
||||
fn end_at_wall(base: &mut Face, wall: &mut Face, ids: (usize, usize), by: f64, bend: &dyn Fn(usize) -> bool, tol: f64) {
|
||||
let (m, n) = (wall.pts.len(), base.pts.len());
|
||||
let at_wall: Vec<usize> = (0..m).filter(|i| wall.across.get(*i).copied().flatten() == Some(ids.0)).collect();
|
||||
let at_base: Vec<usize> = (0..n).filter(|i| base.across.get(*i).copied().flatten() == Some(ids.1)).collect();
|
||||
let (&[k], &[i]) = (at_wall.as_slice(), at_base.as_slice()) else { return };
|
||||
if m < 5 {
|
||||
return;
|
||||
}
|
||||
// Corner `j` and edge `j` (from corner `j`) counted from the bend's edge on.
|
||||
let pt = |j: usize| wall.pts.get((k + j) % m).copied().unwrap_or_default();
|
||||
let (p, q) = (pt(0), pt(1));
|
||||
let Some(d) = (q - p).normalized() else { return };
|
||||
// Up the wall from the bend's edge (the outline runs counter-clockwise about the normal), and
|
||||
// along the edge.
|
||||
let up = |x: Vec3| (x - p).dot(wall.n.cross(d));
|
||||
let along = |x: Vec3| (x - p).dot(d);
|
||||
let free = |j: usize| {
|
||||
let j = (k + j) % m;
|
||||
wall.round.get(j).is_some_and(Option::is_none) && wall.across.get(j).copied().flatten().is_none_or(|g| !bend(g))
|
||||
};
|
||||
let low = |x: Vec3| up(x) > tol && up(x) <= by + tol;
|
||||
// A strip from an end of the bend's edge up to `x1`, back along the edge (`back`: the way it
|
||||
// runs) to `x2`, then the wall's end up to `x3`: where the line of that end meets the edge.
|
||||
let foot = |x1: Vec3, x2: Vec3, x3: Vec3, back: f64| {
|
||||
let rise = up(x3) - up(x2);
|
||||
(low(x1) && low(x2) && (x2 - x1).dot(d) * back > tol && rise > tol).then(|| x2 - (x3 - x2) * (up(x2) / rise))
|
||||
};
|
||||
// At the edge's end the strip is edges 1 and 2, the wall's end edge 3; at its start the strip
|
||||
// is edges m - 1 and m - 2, the wall's end edge m - 3.
|
||||
let ok = |js: [usize; 3]| js.iter().all(|j| free(*j));
|
||||
let end = if ok([1, 2, 3]) { foot(pt(2), pt(3), pt(4), -1.0) } else { None };
|
||||
let start = if ok([m - 1, m - 2, m - 3]) { foot(pt(m - 1), pt(m - 2), pt(m - 3), 1.0) } else { None };
|
||||
let (p1, q1) = (start.unwrap_or(p), end.unwrap_or(q));
|
||||
let len = along(q);
|
||||
let inside = |x: Option<Vec3>| x.is_none_or(|x| along(x) > tol && along(x) < len - tol);
|
||||
if (start.is_none() && end.is_none())
|
||||
|| !inside(start)
|
||||
|| !inside(end)
|
||||
|| along(q1) - along(p1) <= tol
|
||||
|| (start.is_some() && end.is_some() && m < 7)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The wall: the strips' corners and edges go, the bend's edge runs from `p1` to `q1`.
|
||||
let (mut corners_out, mut edges_out) = (Vec::new(), Vec::new());
|
||||
if end.is_some() {
|
||||
corners_out.extend([2, 3]);
|
||||
edges_out.extend([1, 2]);
|
||||
}
|
||||
if start.is_some() {
|
||||
corners_out.extend([m - 1, m - 2]);
|
||||
edges_out.extend([m - 1, m - 2]);
|
||||
}
|
||||
if let Some(x) = wall.pts.get_mut(k) {
|
||||
*x = p1;
|
||||
}
|
||||
if let Some(x) = wall.pts.get_mut((k + 1) % m) {
|
||||
*x = q1;
|
||||
}
|
||||
let kept = |j: usize, out: &[usize]| !out.contains(&((j + m - k) % m));
|
||||
wall.pts = wall.pts.iter().enumerate().filter(|(j, _)| kept(*j, &corners_out)).map(|(_, x)| *x).collect();
|
||||
wall.across = wall.across.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
|
||||
wall.round = wall.round.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
|
||||
// The fixed face's edge: the bend from `p1` to `q1` (it runs the other way along it), no bend
|
||||
// beside it.
|
||||
let (Some(&a), Some(&b)) = (base.pts.get(i), base.pts.get((i + 1) % n)) else { return };
|
||||
let from_a = |x: Vec3| (x - a).dot(b - a);
|
||||
let (x, y) = if from_a(p1) < from_a(q1) { (p1, q1) } else { (q1, p1) };
|
||||
let mut pieces: Vec<(Vec3, Option<usize>)> = Vec::with_capacity(3);
|
||||
if x.dist(a) > tol {
|
||||
pieces.push((a, None));
|
||||
}
|
||||
pieces.push((if x.dist(a) > tol { x } else { a }, Some(ids.1)));
|
||||
if y.dist(b) > tol {
|
||||
pieces.push((y, None));
|
||||
}
|
||||
let count = pieces.len();
|
||||
base.pts.splice(i..=i, pieces.iter().map(|(x, _)| *x));
|
||||
base.across.splice(i..=i, pieces.iter().map(|(_, g)| *g));
|
||||
base.round.splice(i..=i, std::iter::repeat_n(None, count));
|
||||
}
|
||||
|
||||
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
|
||||
/// bend first trimmed to the end of the bend zone.
|
||||
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
@@ -574,7 +672,8 @@ pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<
|
||||
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
|
||||
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
|
||||
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
@@ -696,6 +795,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
|
||||
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
|
||||
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
|
||||
// A wall on part of an edge of the fixed face bends only where it stands (a bend picked as a
|
||||
// chamfer or a cylinder is taken as it is).
|
||||
let walls: Vec<(usize, f64)> = panels.iter().filter(|(_, p)| p.parent == Some(f0)).map(|(g, p)| (*g, p.setback)).collect();
|
||||
for (g, by) in walls {
|
||||
if chamfers.iter().any(|c| (c.a, c.b) == (f0, g) || (c.a, c.b) == (g, f0)) {
|
||||
continue;
|
||||
}
|
||||
let kids: Vec<usize> = panels.iter().filter(|(_, p)| p.parent == Some(g)).map(|(h, _)| *h).collect();
|
||||
let Some(mut wall) = panels.remove(&g) else { continue };
|
||||
if let Some(base) = panels.get_mut(&f0) {
|
||||
end_at_wall(&mut base.face, &mut wall.face, (f0, g), by, &|h| h == f0 || kids.contains(&h), tol);
|
||||
}
|
||||
panels.insert(g, wall);
|
||||
}
|
||||
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
|
||||
// How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall
|
||||
// reaches through the sheet, a pocket's ends on its floor, an island's starts at the face.
|
||||
@@ -872,7 +985,25 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
queue.push((g, Some(sh.flanges.len() - 1)));
|
||||
}
|
||||
}
|
||||
Ok(sh)
|
||||
// The bends on the fixed face finished as an edge flange feature finishes its own
|
||||
// (`SheetBody::add_flanges_each`): the rule's bend relief where one ends beside more of the
|
||||
// fixed face; where two meet at a corner of it, their walls closed with the rule's gap (which
|
||||
// the cuts above already are) and the rule's corner relief.
|
||||
let made: Vec<usize> = (0..sh.flanges.len()).collect();
|
||||
let mut notes = Vec::new();
|
||||
for &i in &made {
|
||||
notes.extend(sh.relieve_bend_ends(i, (false, false)));
|
||||
}
|
||||
let corners = sh.corners(&made);
|
||||
let mut closed = Vec::with_capacity(corners.len());
|
||||
for k in &corners {
|
||||
match sh.close_corner(k) {
|
||||
Ok(()) => closed.push(*k),
|
||||
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
|
||||
}
|
||||
}
|
||||
notes.extend(sh.relieve_corners(&corners, &closed, ReliefOverride::default()));
|
||||
Ok((sh, notes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -30,7 +30,7 @@ fn plate_doc() -> (Document, u64) {
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: s,
|
||||
sketch: crate::SketchId(s),
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
@@ -108,7 +108,7 @@ fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(0.0, 0.0, 10.0)],
|
||||
edges: vec![Position(Vec3::new(0.0, 0.0, 10.0))],
|
||||
radius: "3".into(),
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
@@ -123,7 +123,7 @@ fn box_fillet_cut_timeline() {
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: s2,
|
||||
sketch: crate::SketchId(s2),
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
@@ -162,7 +162,7 @@ fn errors_are_reported_not_fatal() {
|
||||
let (mut doc, s) = plate_doc();
|
||||
doc.add_feature(
|
||||
FeatureKind::Revolve {
|
||||
sketch: s,
|
||||
sketch: crate::SketchId(s),
|
||||
profiles: ProfileSel::All,
|
||||
axis: AxisRef::SketchAxis { axis: "y".into() },
|
||||
angle: "90 deg".into(),
|
||||
@@ -178,7 +178,7 @@ fn errors_are_reported_not_fatal() {
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Fillet {
|
||||
edges: vec![Vec3::new(500.0, 0.0, 0.0)],
|
||||
edges: vec![Position(Vec3::new(500.0, 0.0, 0.0))],
|
||||
radius: "1".into(),
|
||||
body: None,
|
||||
style: Default::default(),
|
||||
@@ -244,7 +244,7 @@ fn primitives_and_combine() {
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: point_expr(Vec3::new(5.0, 5.0, 0.0)),
|
||||
axis: Vec3::Z,
|
||||
axis: Dir(Vec3::Z),
|
||||
radius: "2".into(),
|
||||
height: "20".into(),
|
||||
operation: Operation::NewBody,
|
||||
|
||||
+114
-21
@@ -13,13 +13,13 @@ pub(crate) use crate::expr::Kind;
|
||||
pub(crate) use crate::feature_geometry::Geo;
|
||||
pub(crate) use crate::feature_refs::Ref;
|
||||
pub(crate) use crate::params::ExprRole;
|
||||
use crate::{PlaneRef, PointExpr};
|
||||
use crate::{BodyName, Dir, Expr, FacePick, FeatureName, PlaneRef, PointExpr, Position, SketchId};
|
||||
|
||||
/// An expression: its label, the expression, its kind and its role.
|
||||
pub(crate) type ExprItem<'a> = (&'static str, &'a String, Kind, ExprRole);
|
||||
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut String, Kind, ExprRole);
|
||||
pub(crate) type RefItem<'a> = Ref<&'a u64, &'a String>;
|
||||
pub(crate) type RefItemMut<'a> = Ref<&'a mut u64, &'a mut String>;
|
||||
pub(crate) type ExprItem<'a> = (&'static str, &'a Expr, Kind, ExprRole);
|
||||
pub(crate) type ExprItemMut<'a> = (&'static str, &'a mut Expr, Kind, ExprRole);
|
||||
pub(crate) type RefItem<'a> = Ref<&'a SketchId, &'a FeatureName, &'a BodyName>;
|
||||
pub(crate) type RefItemMut<'a> = Ref<&'a mut SketchId, &'a mut FeatureName, &'a mut BodyName>;
|
||||
pub(crate) type GeoItem<'a> = Geo<&'a Vec3, &'a PlaneRef, &'a GeoRef, &'a PointExpr, &'a ProfileSel>;
|
||||
pub(crate) type GeoItemMut<'a> = Geo<&'a mut Vec3, &'a mut PlaneRef, &'a mut GeoRef, &'a mut PointExpr, &'a mut ProfileSel>;
|
||||
|
||||
@@ -40,10 +40,14 @@ impl Walk for bool {}
|
||||
impl Walk for u64 {}
|
||||
impl Walk for i8 {}
|
||||
impl Walk for f64 {}
|
||||
impl Walk for usize {}
|
||||
impl Walk for u32 {}
|
||||
impl Walk for ProfileSel {}
|
||||
impl Walk for solidcraft_sketch::Sketch {}
|
||||
impl Walk for solidcraft_sketch::PatternAlignment {}
|
||||
impl Walk for solidcraft_kernel::StepNode {}
|
||||
impl Walk for crate::document::Operation {}
|
||||
impl Walk for solidcraft_kernel::Continuity {}
|
||||
impl Walk for crate::document::Direction {}
|
||||
impl Walk for crate::document::FilletFit {}
|
||||
impl Walk for crate::document::MeshData {}
|
||||
@@ -55,6 +59,52 @@ impl Walk for crate::document::RuledMode {}
|
||||
impl Walk for crate::document::RuledCorners {}
|
||||
impl Walk for crate::document::ExtrudeConvention {}
|
||||
|
||||
/// A reference walks itself: a sketch, a feature or a body.
|
||||
macro_rules! reference {
|
||||
($t:ty, $role:ident) => {
|
||||
impl Walk for $t {
|
||||
fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) {
|
||||
f(Ref::$role(self))
|
||||
}
|
||||
fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) {
|
||||
f(Ref::$role(self))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reference!(SketchId, Sketch);
|
||||
reference!(FeatureName, Feature);
|
||||
reference!(BodyName, Body);
|
||||
|
||||
/// A point walks itself with its role: a face pick, a position or a direction. As a plain
|
||||
/// `Vec3` it also serves a field whose attribute gives it another role (`pinned`, `pick_if`).
|
||||
macro_rules! point {
|
||||
($t:ty, $role:ident) => {
|
||||
impl Walk for $t {
|
||||
fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) {
|
||||
f(Geo::$role(&self.0))
|
||||
}
|
||||
fn walk_geometry_mut<'a>(&'a mut self, f: &mut dyn FnMut(GeoItemMut<'a>)) {
|
||||
f(Geo::$role(&mut self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Each<Vec3> for $t {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a Vec3)) {
|
||||
f(&self.0)
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut Vec3)) {
|
||||
f(&mut self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
point!(FacePick, Pick);
|
||||
point!(Position, Pos);
|
||||
point!(Dir, Dir);
|
||||
|
||||
/// Containers walk what they hold, in order.
|
||||
macro_rules! walk_each {
|
||||
($([$($g:tt)*] $t:ty),*) => {$(
|
||||
@@ -136,6 +186,36 @@ impl<T> Each<T> for Vec<T> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, const N: usize> Each<T> for [T; N] {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
for t in self {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
for t in self {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, const N: usize> Each<T> for Option<[T; N]> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
if let Some(a) = self {
|
||||
for t in a {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(&'a mut T)) {
|
||||
if let Some(a) = self {
|
||||
for t in a {
|
||||
f(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Each<T> for Option<Vec<T>> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(&'a T)) {
|
||||
for t in self.iter().flatten() {
|
||||
@@ -151,36 +231,36 @@ impl<T> Each<T> for Option<Vec<T>> {
|
||||
|
||||
/// The expressions of an expression field, each with its index and their count (for its label).
|
||||
pub(crate) trait ExprField {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String));
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String));
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr));
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr));
|
||||
}
|
||||
|
||||
impl ExprField for String {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
impl ExprField for Expr {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
|
||||
f(0, 1, self)
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
|
||||
f(0, 1, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// A position along an edge with its radius (a variable fillet's mid radii).
|
||||
impl ExprField for (f64, String) {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
impl ExprField for (f64, Expr) {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
|
||||
f(0, 1, &self.1)
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
|
||||
f(0, 1, &mut self.1)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ExprField> ExprField for Option<T> {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
|
||||
if let Some(t) = self {
|
||||
t.each(f)
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
|
||||
if let Some(t) = self {
|
||||
t.each_mut(f)
|
||||
}
|
||||
@@ -191,13 +271,13 @@ impl<T: ExprField> ExprField for Option<T> {
|
||||
macro_rules! expr_list {
|
||||
($([$($g:tt)*] $t:ty),*) => {$(
|
||||
impl<$($g)*> ExprField for $t {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a String)) {
|
||||
fn each<'a>(&'a self, f: &mut dyn FnMut(usize, usize, &'a Expr)) {
|
||||
let n = self.len();
|
||||
for (i, t) in self.iter().enumerate() {
|
||||
t.each(&mut |_, _, e| f(i, n, e));
|
||||
}
|
||||
}
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut String)) {
|
||||
fn each_mut<'a>(&'a mut self, f: &mut dyn FnMut(usize, usize, &'a mut Expr)) {
|
||||
let n = self.len();
|
||||
for (i, t) in self.iter_mut().enumerate() {
|
||||
t.each_mut(&mut |_, _, e| f(i, n, e));
|
||||
@@ -233,8 +313,9 @@ impl<F: Fn(usize, usize) -> &'static str> Label for F {
|
||||
}
|
||||
}
|
||||
|
||||
/// The offsets and angles of a plane, outermost first (nesting is capped like every walk over a
|
||||
/// plane), the construction plane it is built on, and the plane itself.
|
||||
/// The offsets and angles of a plane, outermost first, and an explicit origin's coordinates
|
||||
/// (nesting is capped like every walk over a plane), the construction plane it is built on, and
|
||||
/// the plane itself.
|
||||
impl Walk for PlaneRef {
|
||||
fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) {
|
||||
let mut p = self;
|
||||
@@ -249,6 +330,12 @@ impl Walk for PlaneRef {
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
PlaneRef::Placed { origin, .. } => {
|
||||
for (label, e) in ["X", "Y", "Z"].into_iter().zip(origin) {
|
||||
f((label, e, Kind::Length, ExprRole::Placement));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
@@ -266,6 +353,12 @@ impl Walk for PlaneRef {
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AlongPath { t, .. } => return f(("Distance (0 to 1)", t, Kind::Unitless, ExprRole::Input)),
|
||||
PlaneRef::Placed { origin, .. } => {
|
||||
for (label, e) in ["X", "Y", "Z"].into_iter().zip(origin) {
|
||||
f((label, e, Kind::Length, ExprRole::Placement));
|
||||
}
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
}
|
||||
@@ -274,7 +367,7 @@ impl Walk for PlaneRef {
|
||||
let mut p = self;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
|
||||
_ => return,
|
||||
}
|
||||
@@ -284,7 +377,7 @@ impl Walk for PlaneRef {
|
||||
let mut p = self;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => p = base,
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => p = base,
|
||||
PlaneRef::Construction { name } => return f(Ref::Feature(name)),
|
||||
_ => return,
|
||||
}
|
||||
|
||||
@@ -267,3 +267,48 @@ fn lip_swept_flush_round_a_corner_joins() {
|
||||
let volume = b.body.tessellate(1e-5).unwrap().measure().volume;
|
||||
assert!((volume - block - lip).abs() < 0.02, "{volume} expected {}", block + lip);
|
||||
}
|
||||
|
||||
/// #1275: an Extrude Cut of a whole face of a rounded-rectangle block with a bottom chamfer. The
|
||||
/// tool's corner cylinders lay on the block's; they are grown off them now, as straight edges
|
||||
/// were, and the cut takes 5 mm off the top.
|
||||
#[test]
|
||||
fn cut_of_a_whole_face_beside_round_corners_after_a_chamfer() {
|
||||
let s = replay(include_str!("../../../examples/regressions/cut_whole_face_after_bottom_chamfer.json"));
|
||||
let bodies = &s.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1);
|
||||
let b = &bodies[0];
|
||||
assert!(b.body.validity().is_empty(), "{:?}", b.body.validity());
|
||||
let pi = std::f64::consts::PI;
|
||||
let block = (40.0 * 40.0 - (4.0 - pi) * 8.0 * 8.0) * 15.0;
|
||||
// The chamfer's 2 × 2 triangle swept along the loop (Pappus: its centroid is 2/3 mm in).
|
||||
let chamfer = 2.0 * (4.0 * 24.0 + 2.0 * pi * (8.0 - 2.0 / 3.0));
|
||||
let mesh = b.body.tessellate(1e-4).unwrap();
|
||||
let volume = mesh.measure().volume;
|
||||
assert!((volume - (block - chamfer)).abs() < 0.05, "{volume} expected {}", block - chamfer);
|
||||
let top = mesh.bounds().max.z;
|
||||
assert!((top - 15.0).abs() < 1e-6, "top at {top}");
|
||||
}
|
||||
|
||||
/// The display tessellation of loft bodies joined into a bike frame plans at every display
|
||||
/// quality and every refinement step. The edge nodes on an intersection of two lofts once
|
||||
/// stepped back along the curve (the edge's fit rang where truck's parameter changes speed, and
|
||||
/// took a leader point off the curve past a vertex), so a face's boundary crossed itself and
|
||||
/// the body fell back to the reference mesher, which drew a sheet across the down tube.
|
||||
#[test]
|
||||
fn joined_loft_frames_plan_their_display_at_every_quality() {
|
||||
use solidcraft_doc::display_quality::{DisplayQuality, MAX_REFINE};
|
||||
for (script, name) in [
|
||||
(include_str!("../../../examples/regressions/loft_join_bracket_hull_triangle.json"), "DT2"),
|
||||
(include_str!("../../../examples/regressions/front_triangle_hull_triangle.json"), "FrontTriangle"),
|
||||
] {
|
||||
let s = replay(script);
|
||||
let state = s.model.state();
|
||||
let body = &state.body(name).unwrap().body;
|
||||
for q in DisplayQuality::ALL {
|
||||
for k in 0..=MAX_REFINE {
|
||||
let plan = body.display_plan_angular(q.refined_chord(body.size(), k), q.refined_angle(k));
|
||||
assert!(plan.is_ok(), "{name} at {} + {k}: {:?}", q.id(), plan.err());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! How headless pictures of the model are drawn: a renderer the host installs. The engine
|
||||
//! describes what a picture shows ([`crate::view::scene`]); drawing it is a GPU job the engine
|
||||
//! cannot do itself (the GPU crate sits above it), so the application, the CLI and the MCP
|
||||
//! server install one ([`install`]) that draws with the viewport's own pipelines (or, as a last
|
||||
//! resort the host chooses, the CPU rasterizer). Every picture names the renderer that drew it.
|
||||
//! Without a renderer, a picture is an error and a save simply has no thumbnail.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use solidcraft_render::{Camera, Scene};
|
||||
|
||||
/// A picture: straight RGBA, rows top to bottom, and the renderer that drew it (`gpu`,
|
||||
/// `gpu-software` or `cpu`).
|
||||
pub struct Picture {
|
||||
pub rgba: Vec<u8>,
|
||||
pub renderer: &'static str,
|
||||
}
|
||||
|
||||
/// A picture as PNG bytes, and the renderer that drew it.
|
||||
pub struct Png {
|
||||
pub bytes: Vec<u8>,
|
||||
pub renderer: &'static str,
|
||||
}
|
||||
|
||||
/// What a reply says when the CPU rasterizer drew the picture.
|
||||
pub const CPU_WARNING: &str = "drawn by the CPU rasterizer (no GPU adapter qualified, or SOLIDCRAFT_GPU=cpu): surface analysis colours, highlights and legends are not drawn; `solidcraft-cli gpu` lists the adapters";
|
||||
|
||||
/// The warning that goes with a picture drawn by `renderer`, if any.
|
||||
pub fn warning(renderer: &str) -> Option<&'static str> {
|
||||
(renderer == "cpu").then_some(CPU_WARNING)
|
||||
}
|
||||
|
||||
/// Draws `scene` seen by `cam` at `width` × `height` pixels (`width * height * 4` bytes of RGBA),
|
||||
/// or says why it could not.
|
||||
pub type Renderer = dyn Fn(&Scene, &Camera, u32, u32) -> Result<Picture, String> + Send + Sync;
|
||||
|
||||
static RENDERER: RwLock<Option<Arc<Renderer>>> = RwLock::new(None);
|
||||
|
||||
/// Make `renderer` draw this process's headless pictures (replacing any installed before).
|
||||
pub fn install(renderer: Arc<Renderer>) {
|
||||
if let Ok(mut r) = RENDERER.write() {
|
||||
*r = Some(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed renderer, if any.
|
||||
pub fn installed() -> Option<Arc<Renderer>> {
|
||||
RENDERER.read().ok().and_then(|r| r.clone())
|
||||
}
|
||||
|
||||
/// `scene` drawn by the installed renderer.
|
||||
pub fn rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
|
||||
rgba_with(installed().as_deref(), scene, cam, width, height)
|
||||
}
|
||||
|
||||
/// `scene` drawn by the installed renderer, as PNG.
|
||||
pub fn png(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
|
||||
png_with(installed().as_deref(), scene, cam, width, height)
|
||||
}
|
||||
|
||||
/// [`rgba`] with `renderer` instead of the installed one.
|
||||
pub fn rgba_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Picture, String> {
|
||||
let renderer = renderer.ok_or("no renderer is installed to draw pictures in this process")?;
|
||||
let picture = renderer(scene, cam, width, height)?;
|
||||
if picture.rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
|
||||
return Err(format!("the renderer returned {} bytes for {width} × {height} pixels", picture.rgba.len()));
|
||||
}
|
||||
if let Some(w) = warning(picture.renderer) {
|
||||
log::warn!("{w}");
|
||||
}
|
||||
Ok(picture)
|
||||
}
|
||||
|
||||
/// [`png`] with `renderer` instead of the installed one.
|
||||
pub fn png_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result<Png, String> {
|
||||
let Picture { rgba, renderer } = rgba_with(renderer, scene, cam, width, height)?;
|
||||
let bytes = solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?;
|
||||
Ok(Png { bytes, renderer })
|
||||
}
|
||||
|
||||
/// For tests: a stand-in for the renderer the hosts install, a flat picture whose first byte
|
||||
/// counts the scene's meshes.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_test_renderer() {
|
||||
install(Arc::new(|scene: &Scene, _: &Camera, w: u32, h: u32| {
|
||||
let mut rgba = vec![200; (w * h * 4) as usize];
|
||||
if let Some(first) = rgba.first_mut() {
|
||||
*first = scene.meshes.len() as u8;
|
||||
}
|
||||
Ok(Picture { rgba, renderer: "gpu" })
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn without_a_renderer_a_picture_is_an_error_not_a_crash() {
|
||||
let e = png_with(None, &Scene::default(), &Camera::default(), 16, 16).err().unwrap();
|
||||
assert!(e.contains("no renderer"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renderer_that_returns_the_wrong_size_is_an_error() {
|
||||
let short: &Renderer = &|_, _, _, _| Ok(Picture { rgba: vec![0; 3], renderer: "gpu" });
|
||||
assert!(rgba_with(Some(short), &Scene::default(), &Camera::default(), 2, 2).is_err());
|
||||
let plain: &Renderer = &|_, _, w, h| Ok(Picture { rgba: vec![255; (w * h * 4) as usize], renderer: "cpu" });
|
||||
let png = png_with(Some(plain), &Scene::default(), &Camera::default(), 2, 2).unwrap();
|
||||
assert!(png.bytes.starts_with(b"\x89PNG"));
|
||||
assert_eq!(png.renderer, "cpu");
|
||||
assert_eq!((warning("cpu"), warning("gpu"), warning("gpu-software")), (Some(CPU_WARNING), None, None));
|
||||
}
|
||||
}
|
||||
@@ -837,7 +837,7 @@ mod tests {
|
||||
solidcraft_doc::FeatureKind::Move { bodies, .. } => Some(bodies.clone()),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(moved, Some(vec!["Lid".to_string()]), "the move follows the rename");
|
||||
assert_eq!(moved, Some(vec![solidcraft_doc::BodyName::from("Lid")]), "the move follows the rename");
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ fn explicit_planes(p: &mut PlaneRef) {
|
||||
*p = PlaneRef::Custom { plane };
|
||||
}
|
||||
}
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => explicit_planes(base),
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => explicit_planes(base),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -374,9 +374,14 @@ mod tests {
|
||||
let second = run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy()}))["feature"].clone();
|
||||
assert_eq!(body_names(&s), ["Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
paste_import_with_two_lifts(&mut s, &second, ["Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
// Each import is a component of its own (#1386), shown after the pasted copies: the first
|
||||
// import's bodies stay where they were, the second's and their copies are lifted.
|
||||
let plain = [[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [60.0, 0.0, 0.0]];
|
||||
let at: Vec<Vec<f64>> = plain.iter().chain(&LIFTED).chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
let at: Vec<Vec<f64>> = LIFTED.iter().chain(&plain).chain(&LIFTED).map(|c| c.to_vec()).collect();
|
||||
assert_eq!(corners(&mut s), at, "{:?}", body_names(&s));
|
||||
let m = run(&mut s, "inspect.measure", json!({}));
|
||||
let names: Vec<&str> = m["bodies"].as_array().unwrap().iter().filter_map(|b| b["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Aa (copy)", "Bb (copy)", "Cc (copy)", "Aa", "Bb", "Cc", "Aa (2)", "Bb (2)", "Cc (2)"]);
|
||||
}
|
||||
|
||||
/// A cylinder Emboss finds its face by the persistent name it recorded when it was made. The
|
||||
@@ -522,6 +527,9 @@ mod tests {
|
||||
assert_eq!(def["definition"]["targets"], json!(["Plate (copy)"]), "{def}");
|
||||
let st = s.world_state();
|
||||
let v = |name: &str| solidcraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume;
|
||||
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-6, "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
|
||||
// A spring on the wrong plate is hundreds of mm³ off. The two plates are measured apart
|
||||
// (one moved 50 mm), which the boolean and the measure each round in their own way:
|
||||
// a few 1e-6 mm³ on 5000.
|
||||
assert!((v("Plate") - v("Plate (copy)")).abs() < 1e-8 * v("Plate"), "each plate carries one spring: {} vs {}", v("Plate"), v("Plate (copy)"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Compare Bodies: two versions of one part laid over each other. Both are measured, each
|
||||
//! surface's distance to the other is sampled, and the places where only one of them has
|
||||
//! material are found, measured and, on request, coloured. It works from the surfaces
|
||||
//! ([`solidcraft_geom::compare`]), not from a boolean of the two bodies, so bodies that share
|
||||
//! almost every face compare as well as any.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_geom::compare::{self, CompareOptions, Side};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("inspect.compare", "Compare Bodies", compare_bodies).params(
|
||||
"a, b: body names (default: the two selected bodies, or the design's only two), in world placement; tolerance?: mm (0.001 to 10, default 0.01): \
|
||||
surfaces closer than this agree; spacing?: mm (default 0.5): the largest piece of a face sampled as one point, coarser for a body that would need \
|
||||
more than max_points? (default 8 million, up to 50 million) samples; max_regions? (default 50); color?: true | {only_in_a?, only_in_b?: \"#rrggbb\"} \
|
||||
(colours the faces that hold a region, red where only a has material and green where only b has: the faces mostly beyond the tolerance, else the \
|
||||
one with the largest share; an undo step) → a, b: volume_mm3, area_mm2, center_of_mass, bbox; difference: b − a; \
|
||||
deviation: a_to_b, b_to_a (max_mm, mean_mm, at, area_beyond_mm2); regions, largest first: side (only_in_a | only_in_b), volume_mm3 (integrated along \
|
||||
lines through the region: resolution), area_mm2, max_deviation_mm, at, center, bbox, faces_a, faces_b (face indices, as model.faces lists them); \
|
||||
identical; verdict; warnings (a body whose surface does not enclose what it measures, lines left out). Works from the two surfaces, without a boolean of the bodies, so near-identical bodies compare too",
|
||||
)];
|
||||
|
||||
/// Default colours: red for material only `a` has, green for material only `b` has.
|
||||
const ONLY_IN_A: [u8; 3] = [0xe0, 0x31, 0x31];
|
||||
const ONLY_IN_B: [u8; 3] = [0x2f, 0x9e, 0x44];
|
||||
|
||||
fn side_name(s: Side) -> &'static str {
|
||||
match s {
|
||||
Side::OnlyInA => "only_in_a",
|
||||
Side::OnlyInB => "only_in_b",
|
||||
}
|
||||
}
|
||||
|
||||
/// A number parameter in `range`, `default` when absent.
|
||||
fn number(cmd: &str, p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>) -> Result<f64> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|x| x.is_finite() && range.contains(x))
|
||||
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from {} to {}", range.start(), range.end()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two bodies: `a` and `b`, else the two selected bodies, else the design's only two.
|
||||
fn pair(s: &Session, st: &solidcraft_doc::ModelState, p: &Value, cmd: &str) -> Result<(String, String)> {
|
||||
let given = |k: &str| -> Result<Option<String>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(n)) => Ok(Some(n.clone())),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a body name"))),
|
||||
}
|
||||
};
|
||||
let (a, b) = match (given("a")?, given("b")?) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
(None, None) => {
|
||||
let selected: Vec<String> =
|
||||
s.selection.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||||
let all: Vec<String> = st.bodies.iter().map(|b| b.name.clone()).collect();
|
||||
match (selected.as_slice(), all.as_slice()) {
|
||||
([a, b], _) | (_, [a, b]) => (a.clone(), b.clone()),
|
||||
_ => return Err(bad(cmd, "give `a` and `b` (or select two bodies)")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "give both `a` and `b`")),
|
||||
};
|
||||
for n in [&a, &b] {
|
||||
if st.body(n).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{n}`")));
|
||||
}
|
||||
}
|
||||
if a == b {
|
||||
return Err(bad(cmd, "`a` and `b` must be two different bodies"));
|
||||
}
|
||||
Ok((a, b))
|
||||
}
|
||||
|
||||
fn colours(cmd: &str, p: &Value) -> Result<Option<[[u8; 3]; 2]>> {
|
||||
match p.get("color") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||||
Some(Value::Bool(true)) => Ok(Some([ONLY_IN_A, ONLY_IN_B])),
|
||||
Some(Value::Object(o)) => {
|
||||
let pick = |k: &str, d: [u8; 3]| match o.get(k) {
|
||||
None | Some(Value::Null) => Ok(d),
|
||||
Some(v) => {
|
||||
super::inspect::color_param(v).ok_or_else(|| bad(cmd, format!("`color.{k}` must be \"#rrggbb\" or [r, g, b] with values 0–255")))
|
||||
}
|
||||
};
|
||||
Ok(Some([pick("only_in_a", ONLY_IN_A)?, pick("only_in_b", ONLY_IN_B)?]))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`color` must be true or {only_in_a, only_in_b}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn v3(v: &Value) -> Option<Vec3> {
|
||||
crate::params::vec3(v)
|
||||
}
|
||||
|
||||
/// b − a of the two bodies' measures.
|
||||
fn difference(ma: &Value, mb: &Value) -> Value {
|
||||
let f = |m: &Value, k: &str| m.get(k).and_then(Value::as_f64);
|
||||
let sub = |x: Option<f64>, y: Option<f64>| x.zip(y).map(|(x, y)| y - x);
|
||||
let (va, vb) = (f(ma, "volume_mm3"), f(mb, "volume_mm3"));
|
||||
let com = v3(&ma["center_of_mass"]).zip(v3(&mb["center_of_mass"])).map(|(a, b)| b - a);
|
||||
let corner = |k: &str| v3(&ma["bbox"][k]).zip(v3(&mb["bbox"][k])).map(|(a, b)| b - a);
|
||||
json!({
|
||||
"volume_mm3": sub(va, vb),
|
||||
"volume_percent": va.zip(vb).filter(|(a, _)| a.abs() > 0.0).map(|(a, b)| 100.0 * (b - a) / a),
|
||||
"area_mm2": sub(f(ma, "area_mm2"), f(mb, "area_mm2")),
|
||||
"center_of_mass": com,
|
||||
"center_of_mass_distance_mm": com.map(Vec3::len),
|
||||
"bbox_min": corner("min"),
|
||||
"bbox_max": corner("max"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The volume a closed mesh encloses (divergence theorem, about its box's centre).
|
||||
fn enclosed_volume(m: &solidcraft_geom::Mesh) -> f64 {
|
||||
let o = m.bounds().center();
|
||||
m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| (a - o).dot((b - o).cross(c - o)) / 6.0).sum()
|
||||
}
|
||||
|
||||
/// What makes the comparison less certain: a body whose surface does not enclose what it
|
||||
/// measures (open, or faces that failed), lines left out of region volumes.
|
||||
fn warnings(bodies: [(&str, &Value, &solidcraft_geom::Mesh); 2], chord: f64, c: &compare::Comparison) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (name, measure, mesh) in bodies {
|
||||
let (Some(v), area) = (measure["volume_mm3"].as_f64(), measure["area_mm2"].as_f64().unwrap_or(0.0)) else { continue };
|
||||
let enclosed = enclosed_volume(mesh);
|
||||
// A mesh within `chord` of the surface encloses the volume to within chord × area.
|
||||
if !((enclosed - v).abs() <= chord * area.abs() + 1e-6 * v.abs()) {
|
||||
out.push(format!(
|
||||
"`{name}`: its surface encloses {enclosed:.3} mm³ but the body measures {v:.3} mm³: it is open or has faces that failed, so its measures and the regions near the gap are unreliable"
|
||||
));
|
||||
}
|
||||
}
|
||||
let skipped: usize = c.regions.iter().map(|r| r.lines.skipped).sum();
|
||||
if skipped > 0 {
|
||||
out.push(format!("{skipped} line(s) through the regions crossed a gap in a surface and were left out of their volumes"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn deviation_json(d: &compare::Deviation) -> Value {
|
||||
json!({"max_mm": d.max, "mean_mm": d.mean, "at": d.at, "area_beyond_mm2": d.beyond_area, "area_mm2": d.area, "points": d.points})
|
||||
}
|
||||
|
||||
fn compare_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.compare";
|
||||
let tolerance = number(cmd, p, "tolerance", 0.01, 0.001..=10.0)?;
|
||||
let spacing = number(cmd, p, "spacing", 0.5, 0.01..=1000.0)?;
|
||||
let max_points = number(cmd, p, "max_points", 8e6, 1e4..=5e7)? as usize;
|
||||
let max_regions = number(cmd, p, "max_regions", 50.0, 1.0..=1000.0)? as usize;
|
||||
let colors = colours(cmd, p)?;
|
||||
let st = s.world_state();
|
||||
let (na, nb) = pair(s, &st, p, cmd)?;
|
||||
let (Some(ba), Some(bb)) = (st.body(&na), st.body(&nb)) else { return Err(bad(cmd, "no such body")) };
|
||||
// Meshed within a quarter of the tolerance (the kernel goes no finer than a millionth of a
|
||||
// body's size), so two meshes of one surface agree well within it.
|
||||
let chord = tolerance / 4.0;
|
||||
let mut done = solidcraft_kernel::map_on_threads(&[ba, bb], 2, |b| (super::inspect::measure_json(b), b.body.tessellate_with(chord, true)))
|
||||
.into_iter()
|
||||
.zip([&na, &nb]);
|
||||
let mut next = || -> Result<(Value, solidcraft_geom::Mesh)> {
|
||||
let Some((r, name)) = done.next() else { return Err(bad(cmd, "no such body")) };
|
||||
let (m, mesh) = r.ok_or_else(|| bad(cmd, format!("`{name}`: measuring it failed")))?;
|
||||
let mesh = mesh.map_err(|e| bad(cmd, format!("`{name}`: {e}")))?;
|
||||
if mesh.triangles.is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no surface")));
|
||||
}
|
||||
Ok((m, mesh))
|
||||
};
|
||||
let ((measure_a, mesh_a), (measure_b, mesh_b)) = (next()?, next()?);
|
||||
let options = CompareOptions { tolerance, spacing, max_points, max_regions, ..CompareOptions::default() };
|
||||
let each = |n: usize, job: &(dyn Fn(usize) + Sync)| {
|
||||
let jobs: Vec<usize> = (0..n).collect();
|
||||
solidcraft_kernel::for_each_on_threads(&jobs, 16, |i| job(*i));
|
||||
};
|
||||
let c = compare::compare(&mesh_a, &mesh_b, &options, &each);
|
||||
let axis = |k: usize| ["X", "Y", "Z"].get(k).copied().unwrap_or("?");
|
||||
let regions: Vec<Value> = c
|
||||
.regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
json!({
|
||||
"index": i + 1,
|
||||
"side": side_name(r.side),
|
||||
"material_of": if r.side == Side::OnlyInA { &na } else { &nb },
|
||||
"volume_mm3": r.volume,
|
||||
"area_mm2": r.area_a + r.area_b,
|
||||
"area_a_mm2": r.area_a,
|
||||
"area_b_mm2": r.area_b,
|
||||
"max_deviation_mm": r.max_deviation,
|
||||
"at": r.at,
|
||||
"center": r.center,
|
||||
"bbox": {"min": r.bbox.min, "max": r.bbox.max},
|
||||
"faces_a": r.faces_a.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"faces_b": r.faces_b.iter().map(|x| x.0).collect::<Vec<u32>>(),
|
||||
"resolution": {"along": axis(r.lines.axis), "line_spacing_mm": r.lines.spacing, "lines": r.lines.count, "skipped_lines": r.lines.skipped},
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let total = |side: Side| {
|
||||
let rs: Vec<&compare::Region> = c.regions.iter().filter(|r| r.side == side).collect();
|
||||
// `+ 0.0`: an empty sum is -0.0.
|
||||
(rs.len(), rs.iter().map(|r| r.volume).filter(|v| v.is_finite()).sum::<f64>() + 0.0)
|
||||
};
|
||||
let ((ca, va), (cb, vb)) = (total(Side::OnlyInA), total(Side::OnlyInB));
|
||||
let worst = c.a_to_b.max.max(c.b_to_a.max);
|
||||
let identical = c.regions.is_empty() && c.unlisted == 0;
|
||||
let warnings = warnings([(na.as_str(), &measure_a, &mesh_a), (nb.as_str(), &measure_b, &mesh_b)], chord, &c);
|
||||
let mut verdict = if identical {
|
||||
format!("identical within {tolerance} mm (largest deviation {worst:.4} mm)")
|
||||
} else {
|
||||
let more = if c.unlisted > 0 { format!(" (and {} more, not measured)", c.unlisted) } else { String::new() };
|
||||
format!(
|
||||
"{} {} by more than {tolerance} mm: {ca} with material only in `{na}` ({va:.3} mm³), {cb} only in `{nb}` ({vb:.3} mm³){more}",
|
||||
c.regions.len(),
|
||||
if c.regions.len() == 1 { "region differs" } else { "regions differ" }
|
||||
)
|
||||
};
|
||||
if !warnings.is_empty() {
|
||||
verdict += &format!(" ({} warning{})", warnings.len(), if warnings.len() == 1 { "" } else { "s" });
|
||||
}
|
||||
let mut out = json!({
|
||||
"a": measure_a,
|
||||
"b": measure_b,
|
||||
"difference": difference(&measure_a, &measure_b),
|
||||
"tolerance_mm": tolerance,
|
||||
"deviation": {"a_to_b": deviation_json(&c.a_to_b), "b_to_a": deviation_json(&c.b_to_a), "max_mm": worst},
|
||||
"identical": identical,
|
||||
"verdict": verdict,
|
||||
"regions": regions,
|
||||
"only_in_a": {"regions": ca, "volume_mm3": va},
|
||||
"only_in_b": {"regions": cb, "volume_mm3": vb},
|
||||
"unlisted_regions": c.unlisted,
|
||||
"warnings": warnings,
|
||||
"sampling": {"spacing_mm": c.spacing, "chord_mm": chord, "triangles_a": mesh_a.triangles.len(), "triangles_b": mesh_b.triangles.len()},
|
||||
});
|
||||
if let Some(colors) = colors {
|
||||
out["colored_faces"] = json!(color_regions(s, &c, [&na, &nb], colors)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A face is coloured for a region when at least this share of it deviates there.
|
||||
const MOSTLY: f64 = 0.5;
|
||||
|
||||
/// The faces to colour for a region, as (body: 0 = a, 1 = b; face): those mostly beyond the
|
||||
/// tolerance (a small difference on a large face would colour all of it), or, when it has none,
|
||||
/// the one with the largest share.
|
||||
fn faces_to_color(c: &compare::Comparison, r: &compare::Region) -> Vec<(usize, u32)> {
|
||||
let share = |m: usize, &(f, beyond): &(u32, f64)| {
|
||||
let all = c.face_area.get(m).and_then(|x| x.get(&f)).copied().unwrap_or(0.0);
|
||||
(m, f, if all > 0.0 { beyond / all } else { 0.0 })
|
||||
};
|
||||
let all: Vec<(usize, u32, f64)> = r.faces_a.iter().map(|x| share(0, x)).chain(r.faces_b.iter().map(|x| share(1, x))).collect();
|
||||
let mostly: Vec<(usize, u32)> = all.iter().filter(|x| x.2 >= MOSTLY).map(|x| (x.0, x.1)).collect();
|
||||
if !mostly.is_empty() {
|
||||
return mostly;
|
||||
}
|
||||
all.iter().max_by(|x, y| x.2.total_cmp(&y.2)).map(|x| vec![(x.0, x.1)]).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Give the faces holding each region the colour of its side (a face holding several takes the
|
||||
/// largest one's); returns how many face appearances were set.
|
||||
fn color_regions(s: &mut Session, c: &compare::Comparison, names: [&String; 2], colors: [[u8; 3]; 2]) -> Result<usize> {
|
||||
use solidcraft_doc::appearance::{FaceLook, Look};
|
||||
let st = s.model.state();
|
||||
// Where each face of the two bodies is, as the design file names and finds it. A body placed
|
||||
// by an occurrence is coloured where it is defined, if at all.
|
||||
let found: Vec<_> =
|
||||
names.iter().map(|n| st.body(n).map(|b| (solidcraft_doc::naming::face_spots(b), solidcraft_doc::naming::face_names(b)))).collect();
|
||||
let mut picks: Vec<(String, Vec3, Option<String>, Look)> = Vec::new();
|
||||
// Smallest region first, so the largest one's colour is the one that stays.
|
||||
for r in c.regions.iter().rev() {
|
||||
let colour = if r.side == Side::OnlyInA { colors[0] } else { colors[1] };
|
||||
let mut look = Look::custom(colour, 1.0);
|
||||
look.name = if r.side == Side::OnlyInA { "Only in a".into() } else { "Only in b".into() };
|
||||
for (m, f) in faces_to_color(c, r) {
|
||||
let (Some(Some((spots, face_names))), Some(name)) = (found.get(m), names.get(m)) else { continue };
|
||||
let Some(spot) = spots.get(f as usize).copied().flatten() else { continue };
|
||||
picks.retain(|(b, pt, _, _)| !(b == *name && pt.dist(spot.point) < 1e-9));
|
||||
picks.push(((*name).clone(), spot.point, face_names.get(f as usize).cloned(), look.clone()));
|
||||
}
|
||||
}
|
||||
if picks.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let a = &mut s.doc_mut().appearances;
|
||||
let mut n = 0;
|
||||
for (body, point, name, look) in picks {
|
||||
a.faces.retain(|f| !(f.body == body && f.point.dist(point) < 1e-9));
|
||||
if a.faces.len() >= 100_000 {
|
||||
return Err(bad("inspect.compare", "too many face appearances"));
|
||||
}
|
||||
a.faces.push(FaceLook { body, point, look, name, strict_name: false, certificate: None });
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compare_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,212 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn regions(r: &Value) -> Vec<Value> {
|
||||
r["regions"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn f(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn close(got: f64, want: f64, rel: f64) -> bool {
|
||||
(got - want).abs() <= rel * want.abs()
|
||||
}
|
||||
|
||||
/// A 40 x 30 x 10 box `A` and its copy `A (2)`, in the same place.
|
||||
fn two_boxes() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["A"], "translate": [0, 0, 0], "copy": true}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_and_its_copy_are_identical() {
|
||||
let mut s = two_boxes();
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
|
||||
assert_eq!(r["identical"], true, "{r}");
|
||||
assert_eq!(r["warnings"], json!([]), "{r}");
|
||||
assert!(regions(&r).is_empty());
|
||||
assert!(f(&r["deviation"]["max_mm"]) < 1e-9, "{}", r["deviation"]);
|
||||
assert_eq!(f(&r["difference"]["volume_mm3"]), 0.0);
|
||||
assert!(close(f(&r["a"]["volume_mm3"]), 12_000.0, 1e-9));
|
||||
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("identical within 0.01 mm"), "{}", r["verdict"]);
|
||||
// Without names: the design's only two bodies. Comparing changes nothing.
|
||||
let before = s.doc.clone();
|
||||
let r = run(&mut s, "inspect.compare", json!({}));
|
||||
assert_eq!(r["identical"], true);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pocket_is_material_only_the_plain_box_has() {
|
||||
// b has a 10 x 8 x 0.5 pocket in its top: 40 mm³ that only a has.
|
||||
let mut s = two_boxes();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 8, "height": 0.5, "corner": [10, 10, 9.5], "operation": "cut", "targets": ["A (2)"]}));
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)"}));
|
||||
let rs = regions(&r);
|
||||
assert_eq!(rs.len(), 1, "{r}");
|
||||
let g = &rs[0];
|
||||
assert_eq!(g["side"], "only_in_a");
|
||||
assert_eq!(g["material_of"], "A");
|
||||
assert!(close(f(&g["volume_mm3"]), 40.0, 0.01), "{g}");
|
||||
assert!((f(&g["max_deviation_mm"]) - 0.5).abs() < 1e-6, "{g}");
|
||||
let (lo, hi) = (&g["bbox"]["min"], &g["bbox"]["max"]);
|
||||
for (k, (want_lo, want_hi)) in [(10.0, 20.0), (10.0, 18.0), (9.5, 10.0)].into_iter().enumerate() {
|
||||
assert!((f(&lo[k]) - want_lo).abs() < 0.05 && (f(&hi[k]) - want_hi).abs() < 0.05, "{g}");
|
||||
}
|
||||
let c = &g["center"];
|
||||
assert!((f(&c[0]) - 15.0).abs() < 0.02 && (f(&c[1]) - 14.0).abs() < 0.02 && (f(&c[2]) - 9.75).abs() < 1e-6, "{g}");
|
||||
// The pocket's floor and walls, and a's top.
|
||||
assert_eq!(g["faces_b"].as_array().map(Vec::len), Some(5), "{g}");
|
||||
assert_eq!(g["faces_a"].as_array().map(Vec::len), Some(1), "{g}");
|
||||
assert!(close(f(&r["difference"]["volume_mm3"]), -40.0, 1e-6), "{}", r["difference"]);
|
||||
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 40.0, 0.01));
|
||||
assert_eq!(r["only_in_b"]["regions"], 0);
|
||||
// The other way round, the same material is only in b.
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A (2)", "b": "A"}));
|
||||
assert_eq!(regions(&r).len(), 1);
|
||||
assert_eq!(regions(&r)[0]["side"], "only_in_b");
|
||||
assert_eq!(regions(&r)[0]["material_of"], "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_boss_is_material_only_the_box_with_it_has() {
|
||||
// b has a 6 x 5 x 2 boss on its top: 60 mm³ that only b has.
|
||||
let mut s = two_boxes();
|
||||
run(&mut s, "solid.box", json!({"length": 6, "width": 5, "height": 2, "corner": [5, 5, 10], "operation": "join", "targets": ["A (2)"]}));
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "A", "b": "A (2)", "color": true}));
|
||||
let rs = regions(&r);
|
||||
assert_eq!(rs.len(), 1, "{r}");
|
||||
assert_eq!(rs[0]["side"], "only_in_b");
|
||||
assert!(close(f(&rs[0]["volume_mm3"]), 60.0, 0.01), "{}", rs[0]);
|
||||
assert!((f(&rs[0]["max_deviation_mm"]) - 2.0).abs() < 1e-6);
|
||||
assert!(close(f(&r["difference"]["volume_mm3"]), 60.0, 1e-6));
|
||||
// Coloured green: the boss's top and four sides, all of them beyond the tolerance; not a's top,
|
||||
// of which only the boss's footprint is. One undo step.
|
||||
assert_eq!(r["colored_faces"], 5, "{r}");
|
||||
let green = |s: &Session, body: &str| s.doc.appearances.faces.iter().filter(|x| x.body == body && x.look.color == [0x2f, 0x9e, 0x44]).count();
|
||||
assert_eq!((green(&s, "A (2)"), green(&s, "A")), (5, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.appearances.faces.len(), 0);
|
||||
// Own colours.
|
||||
let r = run(&mut s, "inspect.compare", json!({"color": {"only_in_b": "#0000ff"}}));
|
||||
assert_eq!(r["colored_faces"], 5);
|
||||
assert_eq!(s.doc.appearances.faces.iter().filter(|x| x.look.color == [0, 0, 255]).count(), 5);
|
||||
}
|
||||
|
||||
/// A 100 x 60 sheet-metal plate with a 20 mm flange along y = 0 and a 5 mm hole at (x, 40).
|
||||
fn bracket(s: &mut Session, name: &str, x: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} base")}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "sheet.flange", json!({"sketch": format!("{name} base"), "body_name": name}));
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20, "body": name}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name} hole")}));
|
||||
run(s, "sketch.circle.center", json!({"center": [x, 40], "diameter": 5}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": format!("{name} hole"), "through_all": true, "operation": "cut", "targets": [name]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_moved_2_mm_leaves_two_crescents() {
|
||||
let mut s = Session::default();
|
||||
bracket(&mut s, "Before", 50.0);
|
||||
bracket(&mut s, "After", 52.0);
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After"}));
|
||||
let rs = regions(&r);
|
||||
assert_eq!(rs.len(), 2, "{r}");
|
||||
// Each is a disk of 2.5 mm radius less its overlap with the other, centres 2 mm apart, through
|
||||
// the 2.5 mm sheet: (π r² − 2 r² acos(d / 2r) + (d / 2) √(4 r² − d²)) · t.
|
||||
let (rr, d, t) = (2.5f64, 2.0f64, 2.5);
|
||||
let crescent = (std::f64::consts::PI * rr * rr - 2.0 * rr * rr * (d / (2.0 * rr)).acos() + d / 2.0 * (4.0 * rr * rr - d * d).sqrt()) * t;
|
||||
assert!((crescent - 24.316_34).abs() < 1e-4);
|
||||
for g in &rs {
|
||||
assert!(close(f(&g["volume_mm3"]), crescent, 0.02), "{g}");
|
||||
let c = &g["center"];
|
||||
assert!((f(&c[1]) - 40.0).abs() < 0.05 && (f(&c[2]) - 1.25).abs() < 0.01, "{g}");
|
||||
// Material only Before has is where After's hole is (beyond x = 51), and the reverse.
|
||||
match g["side"].as_str() {
|
||||
Some("only_in_a") => assert!(f(&c[0]) > 51.5, "{g}"),
|
||||
Some("only_in_b") => assert!(f(&c[0]) < 50.5, "{g}"),
|
||||
_ => panic!("{g}"),
|
||||
}
|
||||
}
|
||||
assert_ne!(rs[0]["side"], rs[1]["side"]);
|
||||
assert!(f(&r["difference"]["volume_mm3"]).abs() < 1e-3, "{}", r["difference"]);
|
||||
// Coloured: the hole walls, not the plate's faces, of which only the crescents deviate.
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": "Before", "b": "After", "color": true}));
|
||||
let n = r["colored_faces"].as_u64().unwrap_or(0) as usize;
|
||||
assert!((2..=8).contains(&n), "{r}");
|
||||
let looks = &s.doc.appearances.faces;
|
||||
assert_eq!(looks.len(), n);
|
||||
for l in looks {
|
||||
assert!(((l.point.x - 51.0).powi(2) + (l.point.y - 40.0).powi(2)).sqrt() < 4.0, "{:?}", l.point);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_and_its_step_round_trips_are_identical() {
|
||||
// Curved faces too: a hole and a rounded edge.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 10, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter": 8, "height": 10, "base": [20, 15, 0], "operation": "cut", "targets": ["Part"]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[40, 15, 10]], "radius": 3}));
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-compare-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("part.step");
|
||||
run(&mut s, "file.export", json!({"path": path, "bodies": ["Part"]}));
|
||||
let mut inserted = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
inserted.push(r["created_bodies"][0].as_str().unwrap().to_string());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for (a, b) in [("Part", inserted[0].as_str()), (inserted[0].as_str(), inserted[1].as_str())] {
|
||||
let r = run(&mut s, "inspect.compare", json!({"a": a, "b": b}));
|
||||
assert_eq!(r["identical"], true, "{a} vs {b}: {r}");
|
||||
assert!(f(&r["deviation"]["max_mm"]) < 0.005, "{a} vs {b}: {}", r["deviation"]);
|
||||
assert!(f(&r["difference"]["volume_mm3"]).abs() < 0.05, "{a} vs {b}: {}", r["difference"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_parameters_are_refused() {
|
||||
let mut s = two_boxes();
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "body_name": "C"}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"a": "A"}),
|
||||
json!({"a": "A", "b": "A"}),
|
||||
json!({"a": "A", "b": "nope"}),
|
||||
json!({"a": "A", "b": 3}),
|
||||
json!({"a": "A", "b": "C", "tolerance": 0}),
|
||||
json!({"a": "A", "b": "C", "tolerance": -1}),
|
||||
json!({"a": "A", "b": "C", "tolerance": "a lot"}),
|
||||
json!({"a": "A", "b": "C", "tolerance": 1e300}),
|
||||
json!({"a": "A", "b": "C", "spacing": 0}),
|
||||
json!({"a": "A", "b": "C", "max_regions": 0}),
|
||||
json!({"a": "A", "b": "C", "max_points": 10}),
|
||||
json!({"a": "A", "b": "C", "color": "blue"}),
|
||||
json!({"a": "A", "b": "C", "color": {"only_in_a": "#12345"}}),
|
||||
] {
|
||||
assert!(s.execute("inspect.compare", &p).is_err(), "{p}");
|
||||
}
|
||||
// Two selected bodies are the pair.
|
||||
s.selection = vec![crate::Sel::Body { name: "A".into() }, crate::Sel::Body { name: "C".into() }];
|
||||
let r = run(&mut s, "inspect.compare", json!({}));
|
||||
assert_eq!(r["a"]["name"], "A");
|
||||
assert_eq!(r["b"]["name"], "C");
|
||||
assert_eq!(r["only_in_a"]["regions"], 1, "{r}");
|
||||
assert!(close(f(&r["only_in_a"]["volume_mm3"]), 12_000.0 - 125.0, 0.01), "{r}");
|
||||
assert!(r["verdict"].as_str().unwrap_or_default().starts_with("1 region differs by more than 0.01 mm"), "{}", r["verdict"]);
|
||||
}
|
||||
Loaded 100 of 535 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user