mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea32f8aac | |||
| 034aa60d47 | |||
| 971b223246 | |||
| 8c861f9f46 | |||
| e04a016e7c | |||
| 93a2922c5e | |||
| ff8b09c225 | |||
| 975a08c992 | |||
| 55587f4ac4 | |||
| e94900933d | |||
| 4927214976 | |||
| 57bcc40838 | |||
| 9974413a5b | |||
| 818af2b856 | |||
| 80238a7cfb | |||
| bd65a561c8 | |||
| 776541b081 | |||
| 79a843c963 | |||
| 1bcc3602b4 | |||
| 93684a4728 | |||
| 9db39f3d7d | |||
| 29369a0bb5 | |||
| 5a9e771cc9 | |||
| 63cb48a4ee | |||
| 36c732ddb7 | |||
| fdec948abc | |||
| 7803b3968f | |||
| cbac4eab3f | |||
| 08eff4f9dd | |||
| 9b80163421 | |||
| 164554edba | |||
| 453c6bcb14 | |||
| f44ff18ca4 | |||
| 98cd67f029 | |||
| d9d6af51c6 | |||
| 38c710e72e | |||
| 5b5b051e02 | |||
| 686576e368 | |||
| 68e4186080 | |||
| b66ad0a9c9 | |||
| 2eacbc37f4 | |||
| f2f9c6fe27 | |||
| d21e2b1afd | |||
| bdd2fb1a5c | |||
| e64f1e0427 | |||
| ac362945cc | |||
| 382680d8e8 | |||
| 316b49cd84 | |||
| b0932a487e | |||
| edad8078ba | |||
| 26c0c19ef7 | |||
| 2f43ad5c5b | |||
| 6a8604c55d | |||
| 4098ff9dc8 | |||
| 97383454c6 | |||
| 10d7bd045e | |||
| 6e87fb1a25 | |||
| 30e0db1e05 | |||
| 2b1cb2c4e1 | |||
| f815491936 | |||
| a595805fc8 | |||
| 0379ee76c6 | |||
| 4f7ff8c4d8 | |||
| d2188fb7ab | |||
| 438abbcf82 | |||
| 079a600481 | |||
| 94bd8719aa | |||
| fc3dedfd5c | |||
| 41a62d210c | |||
| 5638259737 | |||
| 090119a5c5 | |||
| b621819c0c | |||
| ec552a5300 | |||
| e0cc740cc3 | |||
| 5de48e916d | |||
| 80af7bbe3e | |||
| 191e983423 | |||
| 2b9445b049 | |||
| 7ddf48ca16 | |||
| d2f6848377 | |||
| 0b6d6fecd8 | |||
| fa691707c2 | |||
| b9a55c74e2 | |||
| 2bb9c81069 | |||
| cedb51b8ea | |||
| 66d13059dd | |||
| 800ea40c0d | |||
| 20442eecd3 | |||
| 22d1cd7032 | |||
| 6a0dc18655 | |||
| e006dbf1af | |||
| 8241a750fa | |||
| 41779cf7bb | |||
| b01b9689c8 | |||
| 4426da6f5d | |||
| 5ead82e627 | |||
| c29b0d2f13 | |||
| 480fd8d517 | |||
| 4c09fa5355 |
@@ -1,6 +1,5 @@
|
||||
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
|
||||
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
|
||||
# (.github/workflows/ci.yml).
|
||||
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
|
||||
|
||||
[profile.ci]
|
||||
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
|
||||
@@ -34,12 +33,14 @@ slow-timeout = { period = "60s" }
|
||||
[profile.ci.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
|
||||
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
|
||||
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
|
||||
# The action matrix sample runs its designs in parallel workers (ACTION_MATRIX_JOBS, set to match in
|
||||
# ci.yml). Its workers reserve that many of nextest's threads, so the cores are not oversubscribed
|
||||
# (a pi-case hole that takes 8 s alone went past the matrix's 90 s action limit when they were),
|
||||
# and it starts first, beside the other slow tests.
|
||||
[[profile.ci.overrides]]
|
||||
filter = "binary(action_matrix) & test(=action_matrix_sample)"
|
||||
threads-required = "num-cpus"
|
||||
threads-required = 12
|
||||
priority = 100
|
||||
|
||||
|
||||
# The slowest tests start first, so a shard does not end with one of them running alone on a
|
||||
|
||||
@@ -7,4 +7,5 @@ self-hosted-runner:
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
|
||||
+117
-48
@@ -8,12 +8,13 @@
|
||||
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
|
||||
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
|
||||
# no native job runs for them, on pull requests or on main.
|
||||
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
|
||||
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
|
||||
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
|
||||
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
|
||||
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
|
||||
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
|
||||
# main saves the build caches; pull requests restore them. On pull requests the test build and the
|
||||
# shards start only after the format and repository checks pass.
|
||||
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
|
||||
# only main commits.
|
||||
# A push to main runs only the repository checks when a passing merge-queue run tested that very
|
||||
# commit, or when nothing a build reads changed since the last push to main whose run passed.
|
||||
# Otherwise it runs everything.
|
||||
@@ -29,14 +30,13 @@
|
||||
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
|
||||
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
|
||||
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
|
||||
# per job: 16 vCPU for Build tests, Clippy and the merge queue's test shards, 8 for compiles, the
|
||||
# other test shards, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
|
||||
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
|
||||
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
|
||||
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
|
||||
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
|
||||
# other run restores read-only. The tests are built once (Build tests) into an archive the shards
|
||||
# run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
|
||||
# other run restores read-only. Nightly and by hand, Build tests also makes an archive the matrix
|
||||
# jobs run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
|
||||
# same.
|
||||
name: CI
|
||||
|
||||
@@ -178,9 +178,12 @@ jobs:
|
||||
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
|
||||
while IFS= read -r -d '' path; do
|
||||
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
|
||||
# What can change a robustness golden: the kernel and the crates a design is built with.
|
||||
# What a pull request re-checks the goldens for: the kernel, truck and the goldens
|
||||
# themselves (a re-record is checked before it is queued). The goldens are frozen, so
|
||||
# on any other change they would only re-check unchanged results; the merge queue,
|
||||
# main and the nightly run every seed before anything lands.
|
||||
case "$path" in
|
||||
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
|
||||
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
|
||||
esac
|
||||
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
|
||||
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
|
||||
@@ -287,8 +290,8 @@ jobs:
|
||||
run: python3 -I scripts/ci/test_affected.py
|
||||
- name: Merge-of-main detection self-test
|
||||
run: python3 -I scripts/ci/test_main_merge.py
|
||||
- name: Claim and triage bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
|
||||
- name: Claim, triage and kernel-paused bot tests
|
||||
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
|
||||
|
||||
# Fast feedback for a pull request that has not been approved for the full CI yet.
|
||||
quick-compile:
|
||||
@@ -337,25 +340,33 @@ jobs:
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
# Fail fast on pull requests: the test archive (and so the shards) waits for the repository
|
||||
# checks, the most common cheap failure; heavy jobs used to run on to the end beside a failed
|
||||
# format check. Main, nightly and Run workflow build regardless, so the action matrix and the
|
||||
# property tests that hang off this job keep their signal.
|
||||
needs: [changes, checks]
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') }}
|
||||
# The archive is for the action matrix and the property tests, which run only nightly and by
|
||||
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
# Fork builds start from main's cache, read-only; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-archive
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
@@ -387,63 +398,94 @@ jobs:
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, checks, build-tests]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') && needs.build-tests.result == 'success' }}
|
||||
runs-on: ${{ github.event_name == 'merge_group' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
name: Native Linux tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
|
||||
# Builds the tests and runs them in one job on a big runner: there is no archive to upload
|
||||
# and download (3 GB on a full run), and the job starts beside the repository checks, not
|
||||
# after them. nextest hands tests to its threads as they free up, the slowest first (priority
|
||||
# in .config/nextest.toml), so they start at once and no core idles at the end.
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
|
||||
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[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"
|
||||
ACTION_MATRIX_JOBS: "12"
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
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
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-tests
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
|
||||
with:
|
||||
tool: cargo-nextest
|
||||
# Each shard runs the archive the Build tests job made.
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: nextest-archive
|
||||
- name: Run tests
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GOLDENS: ${{ needs.changes.outputs.goldens }}
|
||||
SHARD: ${{ matrix.shard }}
|
||||
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
PACKAGES: ${{ needs.changes.outputs.packages }}
|
||||
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
|
||||
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
|
||||
# queue and the nightly run every seed.
|
||||
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
|
||||
run: |
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
|
||||
filter=()
|
||||
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
|
||||
# below is that filter, so a pull request still runs only what it ran before.
|
||||
base='default()'
|
||||
if [ "$EVENT_NAME" = pull_request ]; then
|
||||
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
|
||||
# main and nightly, not on every pull request push.
|
||||
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
|
||||
fi
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
# 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.
|
||||
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
|
||||
base='not (package(solidcraft-kernel) & binary(boolean_props))'
|
||||
elif [ "$GOLDENS" = true ]; then
|
||||
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
|
||||
# also checks every seed but the slow ones, in the same run so they share its cores.
|
||||
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
|
||||
base="(default() & not (binary(action_matrix) & test(=action_matrix_sample))) | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))"
|
||||
fi
|
||||
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
|
||||
rm -f "$junit"
|
||||
# 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.
|
||||
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
|
||||
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
|
||||
# Pull requests test the affected packages (scripts/ci/affected.py).
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
status=0
|
||||
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
|
||||
--ignore-default-filter -E "$base" || status=1
|
||||
if [ "$status" != 0 ]; then
|
||||
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
|
||||
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
|
||||
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
|
||||
@@ -451,11 +493,14 @@ jobs:
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
- name: Upload test timings
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: junit-${{ matrix.shard }}
|
||||
name: junit
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
@@ -560,13 +605,23 @@ jobs:
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-doc
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
@@ -608,13 +663,23 @@ jobs:
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
|
||||
SCCACHE_DIR: /home/runner/.cache/sccache
|
||||
SCCACHE_CACHE_SIZE: 20G
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
|
||||
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
|
||||
# forks mount a clone and never write back, so nothing they build can reach main's cache.
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-oracle
|
||||
path: /home/runner/.cache/sccache
|
||||
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
|
||||
# Main saves the cache; every other run restores it, read-only.
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
@@ -746,6 +811,7 @@ jobs:
|
||||
DOCTESTS: ${{ needs.changes.outputs.doctests }}
|
||||
CLIPPY: ${{ needs.clippy.result }}
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
ORACLE: ${{ needs.oracle.result }}
|
||||
@@ -760,7 +826,10 @@ jobs:
|
||||
fi
|
||||
if [ "$NATIVE" = true ]; then
|
||||
test "$CLIPPY" = success
|
||||
test "$BUILD" = success
|
||||
# The archive for the action matrix and property tests exists nightly and by hand only.
|
||||
if [ "$EVENT_NAME" = schedule ] || [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
test "$BUILD" = success
|
||||
fi
|
||||
test "$TESTS" = success
|
||||
fi
|
||||
if [ "$DOCTESTS" = true ]; then
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
|
||||
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
|
||||
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
|
||||
name: Kernel paused
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
|
||||
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
discussion:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read # to check out scripts/kernel-paused
|
||||
issues: write
|
||||
pull-requests: read # to list a PR's changed paths
|
||||
discussions: write
|
||||
|
||||
concurrency:
|
||||
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
paused:
|
||||
name: Kernel paused bot
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
sparse-checkout: scripts/kernel-paused
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const paused = require('./scripts/kernel-paused/kernel-paused.js');
|
||||
const done = context.eventName === 'discussion'
|
||||
? await paused.handleDiscussion({ github, context })
|
||||
: await paused.handleOpened({ github, context });
|
||||
core.info(done);
|
||||
@@ -8,7 +8,7 @@
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
|
||||
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
|
||||
# macOS universal .dmg, and the CLI as a .zip packaging/macos
|
||||
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
|
||||
# Web a .zip of the static site packaging/web
|
||||
#
|
||||
@@ -83,6 +83,28 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
@@ -271,19 +293,11 @@ jobs:
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
# The two halves of the universal binary build in parallel on their own runners (each alone
|
||||
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
|
||||
macos-build:
|
||||
name: macOS build ${{ matrix.arch }}
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: aarch64, target: aarch64-apple-darwin }
|
||||
- { arch: x86_64, target: x86_64-apple-darwin }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -291,45 +305,12 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-${{ matrix.arch }}
|
||||
- name: Build
|
||||
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
|
||||
# Not named release-*: the release job collects every release-* artifact as an asset.
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: macos-bin-${{ matrix.arch }}
|
||||
path: |
|
||||
target/${{ matrix.target }}/release/solidcraft
|
||||
target/${{ matrix.target }}/release/solidcraft-cli
|
||||
target/${{ matrix.target }}/release/solvecraft-cli
|
||||
if-no-files-found: error
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: [version, macos-build]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: macos-bin-aarch64
|
||||
path: target/aarch64-apple-darwin/release
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: macos-bin-x86_64
|
||||
path: target/x86_64-apple-darwin/release
|
||||
- name: Join, package and check (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: |
|
||||
# Artifacts don't keep the executable bit.
|
||||
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
|
||||
packaging/macos/package.sh --arch universal --skip-build
|
||||
key: release-macos-universal
|
||||
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
@@ -338,12 +319,9 @@ jobs:
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
needs: [version, icons]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# An 8 vCPU runner: the release build was 35 minutes on a 2 vCPU windows-latest and is the
|
||||
# longest job of the release. The installer icon is the committed
|
||||
# assets/icons/app/windows/exports/solidcraft.ico (package.ps1's default), so nothing waits.
|
||||
runs-on: blacksmith-8vcpu-windows-2025
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -356,6 +334,10 @@ jobs:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -369,6 +351,8 @@ jobs:
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
|
||||
+18
-1
@@ -57,6 +57,23 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
|
||||
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
|
||||
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_inch.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_placed.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_seam.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/cylinder_twice.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/dome.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/dome_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/far_cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/grooved_shaft.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/plate_100_holes.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/sheet_disc.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
|
||||
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
@@ -77,7 +94,6 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
|
||||
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
@@ -102,6 +118,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
|
||||
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
|
||||
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
|
||||
|
||||
+10
-1
@@ -15,6 +15,15 @@ Before choosing a task, check the [command inventory](docs/features.md) and
|
||||
|
||||
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
|
||||
|
||||
## Kernel work is paused
|
||||
|
||||
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
|
||||
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
|
||||
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
|
||||
`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused`
|
||||
label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss
|
||||
are still taken as issues, and everything outside the kernel carries on as normal.
|
||||
|
||||
## Get it running (about 10 minutes on a fast machine)
|
||||
|
||||
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
|
||||
@@ -199,7 +208,7 @@ Check that validation after merging. They are listed in
|
||||
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
|
||||
|
||||
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that change the kernel, truck or the goldens run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
|
||||
`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`, the `atomic_commands` contract tests
|
||||
|
||||
Generated
+18
-17
@@ -4162,7 +4162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -4193,13 +4193,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-3dx-client"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-gpu",
|
||||
@@ -4208,7 +4209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4217,7 +4218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4234,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"schemars",
|
||||
@@ -4253,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4262,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -4279,7 +4280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"cadmpeg-core",
|
||||
"cadmpeg-ir",
|
||||
@@ -4302,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4322,7 +4323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -4331,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
@@ -4345,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4356,14 +4357,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-space-mouse"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"egui",
|
||||
@@ -4390,7 +4391,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5920,7 +5921,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -23,6 +23,7 @@ path = "src/alias.rs"
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-gpu = { workspace = true }
|
||||
solidcraft-mcp = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! What the engine logs (`log::info!`, `log::debug!`: why a body has no display plan, why a save
|
||||
//! left its model cache out), written to stderr. Off unless asked for, so the CLI's output and
|
||||
//! the scripts reading it stay as they are: `--debug` shows SolidCraft's own debug messages (and
|
||||
//! other crates' warnings, whose debug output is large), `RUST_LOG=error|warn|info|debug|trace`
|
||||
//! sets one level for every crate.
|
||||
|
||||
use log::{LevelFilter, Log, Metadata, Record};
|
||||
|
||||
struct Stderr {
|
||||
/// The level for SolidCraft's crates.
|
||||
ours: LevelFilter,
|
||||
/// The level for every other crate.
|
||||
others: LevelFilter,
|
||||
}
|
||||
|
||||
impl Log for Stderr {
|
||||
fn enabled(&self, m: &Metadata) -> bool {
|
||||
m.level() <= if m.target().starts_with("solidcraft") { self.ours } else { self.others }
|
||||
}
|
||||
|
||||
fn log(&self, r: &Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
use std::io::Write as _;
|
||||
// A closed stderr is no reason to stop.
|
||||
let _ = writeln!(std::io::stderr().lock(), "[{} {}] {}", r.level().as_str().to_ascii_lowercase(), r.target(), r.args());
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Removes `--debug` from `args`, wherever it is (every subcommand takes it), and installs the
|
||||
/// logger when it or `RUST_LOG` asks for one.
|
||||
pub fn init(args: &mut Vec<String>) {
|
||||
let before = args.len();
|
||||
args.retain(|a| a != "--debug");
|
||||
let debug = args.len() != before;
|
||||
let env = std::env::var("RUST_LOG").ok().and_then(|v| v.trim().parse::<LevelFilter>().ok());
|
||||
let (ours, others) = match (debug, env) {
|
||||
(false, Some(all)) => (all, all),
|
||||
(true, Some(all)) => (all.max(LevelFilter::Debug), all),
|
||||
(true, None) => (LevelFilter::Debug, LevelFilter::Warn),
|
||||
(false, None) => return,
|
||||
};
|
||||
// Installed once, for the life of the process.
|
||||
if log::set_logger(Box::leak(Box::new(Stderr { ours, others }))).is_ok() {
|
||||
log::set_max_level(ours.max(others));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@
|
||||
//! MCP server on stdio (docs/mcp.md)
|
||||
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
|
||||
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
|
||||
//!
|
||||
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -24,6 +26,7 @@
|
||||
mod bench_agent;
|
||||
mod crashes;
|
||||
mod fuzz;
|
||||
mod logger;
|
||||
mod oracle;
|
||||
mod preview;
|
||||
mod recipe;
|
||||
@@ -82,10 +85,13 @@ const USAGE: &str = "usage:
|
||||
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
|
||||
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
|
||||
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
logger::init(&mut args);
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => cmd_run(&args[1..]),
|
||||
Some("eval" | "inspect") => cmd_eval(&args[1..]),
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
@@ -178,11 +179,137 @@ struct Pending {
|
||||
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);
|
||||
/// Every request the app has been sent and not answered yet, from every connection, for
|
||||
/// `control.running` (#3654). Each connection's own thread answers that, so it never waits behind
|
||||
/// a running request.
|
||||
struct Running {
|
||||
list: Mutex<Vec<InFlight>>,
|
||||
connections: AtomicU64,
|
||||
keys: AtomicU64,
|
||||
}
|
||||
|
||||
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
|
||||
/// What `control.running` says about a request: never its parameters beyond the command id.
|
||||
struct InFlight {
|
||||
key: u64,
|
||||
connection: u64,
|
||||
id: Value,
|
||||
method: String,
|
||||
command: Option<&'static str>,
|
||||
steps: Option<usize>,
|
||||
since: Instant,
|
||||
started: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The app's requests, from both of its listeners.
|
||||
static RUNNING: Running = Running::new();
|
||||
|
||||
impl Running {
|
||||
const fn new() -> Self {
|
||||
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
|
||||
self.list.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// List `req`, sent on `connection`, until the returned ticket is dropped.
|
||||
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
|
||||
let key = self.keys.fetch_add(1, Ordering::Relaxed);
|
||||
// Ids and methods come from the client: only short ones are repeated to the others.
|
||||
let id = match id {
|
||||
Value::Number(_) => id.clone(),
|
||||
Value::String(s) if s.len() <= 64 => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let p = &req.params;
|
||||
let command = match req.method.as_str() {
|
||||
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
|
||||
_ => None,
|
||||
};
|
||||
let steps = match req.method.as_str() {
|
||||
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
|
||||
_ => None,
|
||||
};
|
||||
let method = req.method.chars().take(64).collect();
|
||||
let started = req.started.clone();
|
||||
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
|
||||
Ticket { running: self, key }
|
||||
}
|
||||
|
||||
/// The `control.running` result, as `asker`'s connection sees it.
|
||||
fn result(&self, asker: u64) -> Value {
|
||||
let rows: Vec<Value> = self
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut r = json!({
|
||||
"connection": e.connection,
|
||||
"id": e.id,
|
||||
"method": e.method,
|
||||
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
|
||||
"queued": !e.started.load(Ordering::Relaxed),
|
||||
});
|
||||
if let Some(c) = e.command {
|
||||
r["command"] = json!(c);
|
||||
}
|
||||
if let Some(n) = e.steps {
|
||||
r["steps"] = json!(n);
|
||||
}
|
||||
if e.connection == asker {
|
||||
r["self"] = json!(true);
|
||||
}
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
json!({"running": rows})
|
||||
}
|
||||
}
|
||||
|
||||
/// A listed request; dropped when its reply arrives.
|
||||
struct Ticket {
|
||||
running: &'static Running,
|
||||
key: u64,
|
||||
}
|
||||
|
||||
impl Drop for Ticket {
|
||||
fn drop(&mut self) {
|
||||
let key = self.key;
|
||||
self.running.lock().retain(|e| e.key != key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
|
||||
/// until the app answers, also once its connection has closed (#3654).
|
||||
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
|
||||
let (late_tx, late_rx) = channel();
|
||||
// A small thread per timed-out request (each connection has at most one). If none can be
|
||||
// made, the reply is lost and `control.pending` says the app ended the request without one.
|
||||
// The reply is passed on before the request leaves the list: once `control.running` no longer
|
||||
// shows it, its connection's `control.pending` has it.
|
||||
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
|
||||
if let Ok(v) = reply.recv() {
|
||||
let _ = late_tx.send(v);
|
||||
}
|
||||
drop(late_tx);
|
||||
drop(ticket);
|
||||
});
|
||||
late_rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
|
||||
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
|
||||
}
|
||||
|
||||
fn serve_with(
|
||||
stream: TcpStream,
|
||||
token: &str,
|
||||
tx: Sender<ControlRequest>,
|
||||
ctx: egui::Context,
|
||||
patient: bool,
|
||||
waits: Waits,
|
||||
running: &'static Running,
|
||||
) {
|
||||
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
@@ -216,13 +343,16 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
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 request that timed out may have finished since (#1618).
|
||||
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
|
||||
// A request that timed out may have finished since (#1618). `control.running` only
|
||||
// looks, so it leaves that reply where it is.
|
||||
let late = pending.as_ref().filter(|_| method != "control.running").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" {
|
||||
let mut r = if method == "control.running" {
|
||||
json!({"ok": true, "result": running.result(connection)})
|
||||
} else 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}}),
|
||||
@@ -243,6 +373,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
// 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);
|
||||
let ticket = running.add(connection, &id, &req);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -251,7 +382,7 @@ fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: e
|
||||
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 });
|
||||
pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) });
|
||||
r
|
||||
}
|
||||
}
|
||||
@@ -307,9 +438,10 @@ mod tests {
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
|
||||
static RUNNING: Running = Running::new();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(stream) = listener.incoming().flatten().next() {
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits);
|
||||
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
|
||||
}
|
||||
});
|
||||
let s = TcpStream::connect(addr).unwrap();
|
||||
@@ -349,6 +481,102 @@ mod tests {
|
||||
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
|
||||
}
|
||||
|
||||
/// #3654: `control.running` answers on every connection, at once, with what any connection
|
||||
/// has the app running or queued (command ids, never parameters); a timed-out request stays
|
||||
/// listed until the app answers it, and an idle app lists nothing.
|
||||
#[test]
|
||||
fn every_connection_sees_what_the_app_is_running() {
|
||||
static RUNNING: Running = Running::new();
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// The window: one request at a time, as `drain_control` takes them; the export is held
|
||||
// until the test lets it go.
|
||||
let (gate_tx, gate_rx) = channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
req.started.store(true, Ordering::Relaxed);
|
||||
if req.method == "engine.execute" {
|
||||
let _ = gate_rx.recv();
|
||||
}
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
|
||||
}
|
||||
});
|
||||
let open = || {
|
||||
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);
|
||||
move |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 running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
|
||||
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
|
||||
if until(&rows) || Instant::now() > deadline {
|
||||
return (r, rows);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
};
|
||||
let mut watcher = open();
|
||||
let (r, rows) = running(&mut watcher, &|_| true);
|
||||
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
|
||||
|
||||
let mut export = open();
|
||||
let path = "/private/part.step";
|
||||
let params = json!({"command": "file.export", "params": {"path": path}});
|
||||
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
|
||||
assert_eq!(r["running"], true, "{r}");
|
||||
// A batch from another connection waits behind the export.
|
||||
let mut batch = open();
|
||||
let queued = std::thread::spawn(move || {
|
||||
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
|
||||
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
|
||||
});
|
||||
// The window may not have taken the export yet, even though it timed out.
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
|
||||
assert_eq!(rows.len(), 2, "{r}");
|
||||
let (e, b) = (&rows[0], &rows[1]);
|
||||
assert_eq!(
|
||||
(&e["id"], &e["method"], &e["command"], &e["queued"]),
|
||||
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
|
||||
"{r}"
|
||||
);
|
||||
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
|
||||
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
|
||||
assert_ne!(e["connection"], b["connection"], "{r}");
|
||||
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
|
||||
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
|
||||
// The connection whose request timed out may ask too, and sees its own marked.
|
||||
let r = export(json!({"id": 8, "method": "control.running"}));
|
||||
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
|
||||
assert_eq!(own.len(), 1, "{r}");
|
||||
assert_eq!(own[0]["command"], "file.export", "{r}");
|
||||
|
||||
gate_tx.send(()).unwrap();
|
||||
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
|
||||
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
|
||||
assert!(rows.is_empty(), "done: {r}");
|
||||
// Asking `control.running` did not take the export's reply from its connection.
|
||||
let r = export(json!({"id": 9, "method": "control.pending"}));
|
||||
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
|
||||
}
|
||||
|
||||
/// #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.
|
||||
|
||||
@@ -141,18 +141,13 @@ new false
|
||||
normal false
|
||||
offset false
|
||||
offset_face false
|
||||
offset_one_side false
|
||||
offset_symmetric false
|
||||
offset_two_sides false
|
||||
open false
|
||||
orbit false
|
||||
origin false
|
||||
pan false
|
||||
params false
|
||||
paste false
|
||||
pattern_center false
|
||||
pattern_circ false
|
||||
pattern_flip false
|
||||
pattern_path false
|
||||
pattern_rect false
|
||||
perforate false
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"Center X": "Centre X",
|
||||
"Center Y": "Centre Y",
|
||||
"Center of mass": "Centre of mass",
|
||||
"Customize Toolbar": "Customise Toolbar",
|
||||
"Mass Center": "Mass Centre",
|
||||
"Modeled": "Modelled",
|
||||
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
|
||||
"cmd.inspect_center_of_mass": "Centre of Mass",
|
||||
"cmd.sketch_arc_center_point": "Centre Point Arc",
|
||||
"cmd.sketch_circle_center": "Centre Diameter Circle",
|
||||
"cmd.sketch_rectangle_center": "Centre Rectangle",
|
||||
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
|
||||
"cmd.sketch_slot_center_point": "Centre Point Slot",
|
||||
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
|
||||
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
|
||||
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
|
||||
"sketch_text.align_center": "Centre",
|
||||
"ui.center": "Centre",
|
||||
"ui.centerline": "Centreline",
|
||||
"ui.customizeToolbar": "Customise Toolbar…",
|
||||
"ui.design_properties.license": "Licence",
|
||||
"ui.design_properties.organization": "Organisation",
|
||||
"ui.space_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
|
||||
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
|
||||
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
|
||||
}
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "Licences",
|
||||
"help.mit": "MIT licence",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
|
||||
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
|
||||
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
|
||||
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
|
||||
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
|
||||
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
|
||||
"help.third_party": "Third-party licences",
|
||||
"home.documentation": "Documentation",
|
||||
"home.in_this_browser": "in this browser",
|
||||
@@ -1203,6 +1209,8 @@
|
||||
"ui.agent_chat_view_attached": "View attached",
|
||||
"ui.agent_cursor": "Agent cursor",
|
||||
"ui.agent_cursor_speed": "Agent cursor speed",
|
||||
"ui.agent_off_thread": "Keep the window responsive while agents run commands",
|
||||
"ui.agent_off_thread_note": "An agent's slow commands (from the AI panel or an MCP client) compute beside the window: you can keep turning and inspecting the view, and stop one from the chip in the view. Off: each runs on the window's thread and the window waits for it.",
|
||||
"ui.ai_add": "Add",
|
||||
"ui.ai_add_agent": "Add agent…",
|
||||
"ui.ai_add_agent_title": "Add agent",
|
||||
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "Licences",
|
||||
"help.mit": "Licence MIT",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
|
||||
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
|
||||
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
|
||||
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
|
||||
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
|
||||
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
|
||||
"help.third_party": "Licences tierces",
|
||||
"home.documentation": "Documentation",
|
||||
"home.in_this_browser": "dans ce navigateur",
|
||||
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "ライセンス",
|
||||
"help.mit": "MITライセンス",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
|
||||
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
|
||||
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
|
||||
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
|
||||
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
|
||||
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
|
||||
"help.third_party": "サードパーティライセンス",
|
||||
"home.documentation": "ドキュメント",
|
||||
"home.in_this_browser": "このブラウザ内",
|
||||
@@ -1203,6 +1209,8 @@
|
||||
"ui.agent_chat_view_attached": "ビューを添付済み",
|
||||
"ui.agent_cursor": "エージェントカーソル",
|
||||
"ui.agent_cursor_speed": "エージェントカーソル速度",
|
||||
"ui.agent_off_thread": "エージェントのコマンド実行中もウィンドウを操作可能にする",
|
||||
"ui.agent_off_thread_note": "エージェント(AI パネルまたは MCP クライアント)の時間のかかるコマンドをウィンドウとは別に計算します。実行中もビューの回転や確認ができ、ビュー内の表示から停止できます。オフ:各コマンドはウィンドウのスレッドで実行され、終わるまでウィンドウは待ちます。",
|
||||
"ui.ai_add": "追加",
|
||||
"ui.ai_add_agent": "エージェントを追加…",
|
||||
"ui.ai_add_agent_title": "エージェントを追加",
|
||||
|
||||
@@ -855,6 +855,12 @@
|
||||
"help.licences": "许可证",
|
||||
"help.mit": "MIT 许可证",
|
||||
"help.notice": "NOTICE",
|
||||
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
|
||||
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
|
||||
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
|
||||
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
|
||||
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
|
||||
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
|
||||
"help.third_party": "第三方许可证",
|
||||
"home.documentation": "文档",
|
||||
"home.in_this_browser": "在此浏览器中",
|
||||
@@ -1203,6 +1209,8 @@
|
||||
"ui.agent_chat_view_attached": "已附加视图",
|
||||
"ui.agent_cursor": "代理光标",
|
||||
"ui.agent_cursor_speed": "代理光标速度",
|
||||
"ui.agent_off_thread": "代理运行命令时保持窗口响应",
|
||||
"ui.agent_off_thread_note": "代理(来自 AI 面板或 MCP 客户端)的耗时命令在窗口之外计算:运行时仍可旋转和查看视图,并可在视图中的提示上停止它。关闭:每条命令在窗口线程上运行,窗口会等待其完成。",
|
||||
"ui.ai_add": "添加",
|
||||
"ui.ai_add_agent": "添加智能体…",
|
||||
"ui.ai_add_agent_title": "添加智能体",
|
||||
|
||||
@@ -7,8 +7,13 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
|
||||
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
|
||||
let relative = anchor.source_to_author(doc, author, raw)?;
|
||||
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
|
||||
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solidcraft_kernel::transform_matrix(&raw.body, relative)? };
|
||||
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
|
||||
// In place, the body is the model's own, display mesh included: a sketch projecting the
|
||||
// face's edges meshes it once for the features after it too, rather than a copy.
|
||||
let mut body = if crate::is_identity(&relative) {
|
||||
raw.clone()
|
||||
} else {
|
||||
ModelBody::new(raw.name.clone(), solidcraft_kernel::transform_matrix(&raw.body, relative)?, raw.feature)
|
||||
};
|
||||
body.component = raw.component;
|
||||
body.transferred = raw.transferred;
|
||||
body.source_name = raw.source_name.clone();
|
||||
|
||||
@@ -180,6 +180,9 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
|
||||
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
|
||||
current["format"] = json!("solidcraft/12");
|
||||
crate::format::pieces_in_shell_order(&mut legacy["features"]);
|
||||
for f in legacy["features"].as_array_mut().into_iter().flatten() {
|
||||
f["kernel_piece_order"] = json!(true);
|
||||
}
|
||||
assert_eq!(legacy, current, "the migration preserves every prior field");
|
||||
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
|
||||
assert!(crate::format::read(&legacy.to_string()).is_err());
|
||||
|
||||
@@ -950,6 +950,15 @@ pub enum FeatureKind {
|
||||
/// Normals the same way instead of facing each other.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
/// A plane the bodies move with (an origin or construction plane): its origin and normal
|
||||
/// are the `from` point and normal, worked out again whenever the plane moves.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
from_plane: Option<PlaneRef>,
|
||||
/// A plane the bodies move onto: after turning to meet it (when `from` has a normal) the
|
||||
/// `from` point is brought onto the plane along its normal, so the plane may move and the
|
||||
/// bodies follow. Replaces `to`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
to_plane: Option<PlaneRef>,
|
||||
},
|
||||
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
|
||||
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
|
||||
@@ -2320,6 +2329,12 @@ pub struct Feature {
|
||||
/// where newer ones number them after the first name (`Ribs (4)`, #373).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub shell_piece_order: bool,
|
||||
/// The pieces of a face this feature splits are numbered in the kernel's order and by the
|
||||
/// index of the face they came from, as builds before #3902 numbered them, instead of by
|
||||
/// place. Set on the features of designs saved before then, whose stored `#k` face names
|
||||
/// mean that numbering.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub kernel_piece_order: bool,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
@@ -3021,6 +3036,7 @@ impl Document {
|
||||
face_names: Vec::new(),
|
||||
participants: Vec::new(),
|
||||
shell_piece_order: false,
|
||||
kernel_piece_order: false,
|
||||
kind,
|
||||
};
|
||||
self.place_feature(f);
|
||||
|
||||
+583
-65
File diff suppressed because it is too large
Load Diff
@@ -116,19 +116,35 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
|
||||
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => {
|
||||
// A plane on either side is worked out now, so the bodies follow it when it moves.
|
||||
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
|
||||
if let Some(p) = from_plane {
|
||||
let pl = doc.resolve_plane_in(vals, p, 0, Some(&*st))?;
|
||||
(from, from_normal) = (pl.origin, Some(pl.normal()));
|
||||
}
|
||||
let target = to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?;
|
||||
let (to, to_normal) = match &target {
|
||||
Some(pl) => (pl.origin, Some(pl.normal())),
|
||||
None => (to.0, to_normal.and_then(|n| n.normalized())),
|
||||
};
|
||||
let mut m = translation(Vec3::ZERO);
|
||||
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
|
||||
if let (Some(a), Some(b)) = (from_normal, to_normal) {
|
||||
// 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.0, axis, ang);
|
||||
m = rotation(from, axis, ang);
|
||||
}
|
||||
}
|
||||
let d = to.0 - from.0;
|
||||
// Onto a plane the point goes straight along the plane's normal (nothing slides
|
||||
// across it); onto a point it lands on it.
|
||||
let d = match (&target, to_normal) {
|
||||
(Some(_), Some(n)) => n * (to - from).dot(n),
|
||||
_ => to - from,
|
||||
};
|
||||
m[3][0] += d.x;
|
||||
m[3][1] += d.y;
|
||||
m[3][2] += d.z;
|
||||
@@ -611,7 +627,7 @@ pub(super) fn sheet_eval(
|
||||
)));
|
||||
}
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
|
||||
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the
|
||||
//! tool `whole_face_tool` picks.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::{Plane, Vec2, Vec3};
|
||||
use solidcraft_kernel as kernel;
|
||||
use solidcraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
use crate::*;
|
||||
|
||||
thread_local! {
|
||||
static SLABS: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A cut took a slab over the whole body as its tool, on this thread.
|
||||
pub(super) fn slab_taken() {
|
||||
SLABS.with(|n| n.set(n.get() + 1));
|
||||
}
|
||||
|
||||
/// A closed spline through six points round the origin, on the plane at height `z`.
|
||||
fn spline_sketch(z: f64) -> FeatureKind {
|
||||
let mut sk = Sketch::new();
|
||||
let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)];
|
||||
let mut pts: Vec<usize> = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
pts.push(pts[0]);
|
||||
sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
|
||||
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
|
||||
FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk }
|
||||
}
|
||||
|
||||
fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) {
|
||||
let kind = FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
extent: serde_json::from_value(extent).unwrap(),
|
||||
operation,
|
||||
targets: vec![],
|
||||
};
|
||||
doc.add_feature(kind, None).unwrap();
|
||||
}
|
||||
|
||||
fn volume(model: &Model) -> f64 {
|
||||
let state = model.state();
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity());
|
||||
kernel::measure(&state.bodies[0].body).unwrap().volume
|
||||
}
|
||||
|
||||
/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole
|
||||
/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3
|
||||
/// below the face up: it must take the face's area times 3 and leave the plate alone.
|
||||
#[test]
|
||||
fn slab_cut_into_a_spline_prism() {
|
||||
let mut doc = Document::new("spline prism");
|
||||
let plate = FeatureKind::Box {
|
||||
corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)),
|
||||
length: "40 mm".into(),
|
||||
width: "40 mm".into(),
|
||||
height: "5 mm".into(),
|
||||
operation: Operation::NewBody,
|
||||
};
|
||||
doc.add_feature(plate, None).unwrap();
|
||||
let base = doc.add_feature(spline_sketch(5.0), None).unwrap();
|
||||
extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
let joined = volume(&model);
|
||||
let area = (joined - 40.0 * 40.0 * 5.0) / 10.0;
|
||||
assert!(area > 100.0, "the prism's area {area}");
|
||||
|
||||
let top = doc.add_feature(spline_sketch(15.0), None).unwrap();
|
||||
extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut);
|
||||
SLABS.with(|n| n.set(0));
|
||||
model.evaluate(&doc);
|
||||
let cut = volume(&model);
|
||||
assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab");
|
||||
assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0);
|
||||
let bounds = model.state().bodies[0].mesh().bounds();
|
||||
assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z);
|
||||
assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min);
|
||||
}
|
||||
+37
-3
@@ -330,8 +330,13 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
while let Some(&(_, c)) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
} else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, depth, out.last()) {
|
||||
let st = i;
|
||||
// A leading decimal comma (`,5` for 0,5, #2966) starts the number as `.5` would.
|
||||
let leading_comma = c == ',';
|
||||
if leading_comma {
|
||||
i += 1;
|
||||
}
|
||||
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
|
||||
i += 1;
|
||||
}
|
||||
@@ -340,7 +345,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
// 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) {
|
||||
if depth == 0 && !leading_comma && !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;
|
||||
@@ -413,6 +418,22 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether the comma at `i` starts a number, as a leading decimal comma (`,5` for 0,5, #2966).
|
||||
/// It must be outside parentheses (inside them a comma separates arguments, as for any decimal
|
||||
/// comma), be followed by a digit, and stand where an operand is expected: at the start or right
|
||||
/// after `+ - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning (`2 ,5` and
|
||||
/// `1,5,3` stay errors).
|
||||
fn leading_decimal_comma(cs: &[(usize, char)], i: usize, depth: usize, prev: Option<&(Tok, usize, usize)>) -> bool {
|
||||
depth == 0
|
||||
&& cs.get(i).is_some_and(|(_, c)| *c == ',')
|
||||
&& cs.get(i + 1).is_some_and(|(_, c)| c.is_ascii_digit())
|
||||
&& match prev {
|
||||
None => true,
|
||||
Some((Tok::Op(op), _, _)) => "+-*/^".contains(*op),
|
||||
Some(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`,
|
||||
/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a
|
||||
/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the
|
||||
@@ -991,11 +1012,24 @@ mod tests {
|
||||
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"] {
|
||||
for bad in ["2,", "2 ,5", "2, 5", "1,5,3", "1.5,3", ",5,3", ",5.3", "width ,5", "(1) ,5"] {
|
||||
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leading_decimal_comma_is_a_number() {
|
||||
// #2966: `,5` is 0,5 where `.5` would be a number: at the start or after an operator.
|
||||
assert_eq!(ev(",5", Kind::Unitless).unwrap(), 0.5);
|
||||
assert_eq!(ev(",5 mm", Kind::Length).unwrap(), 0.5);
|
||||
assert_eq!(ev("-,5", Kind::Unitless).unwrap(), -0.5);
|
||||
assert_eq!(ev("2*,5", Kind::Unitless).unwrap(), 1.0);
|
||||
assert_eq!(ev("width - ,25", Kind::Length).unwrap(), 39.75);
|
||||
// Function arguments are still separated by commas.
|
||||
assert_eq!(ev("max(1,5)", Kind::Unitless).unwrap(), 5.0);
|
||||
assert_eq!(ev("max(,5; 2)", Kind::Unitless).ok(), None, "inside parentheses a comma is a separator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inch_and_foot_marks() {
|
||||
// #1502: `1"` in a millimetre design is an inch, `2'` two feet, straight or as primes.
|
||||
|
||||
@@ -1250,7 +1250,9 @@ mod tests {
|
||||
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);
|
||||
let mut kept = plain.features.clone();
|
||||
kept.iter_mut().for_each(|f| f.kernel_piece_order = true);
|
||||
assert_eq!(opened.features, kept);
|
||||
// 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();
|
||||
@@ -1742,7 +1744,7 @@ mod tests {
|
||||
legacy["format"] = Value::from("solidcraft/6");
|
||||
let original_table = legacy["configs"].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
|
||||
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
|
||||
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
|
||||
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
|
||||
assert_eq!(upgraded.format, format_string(FORMAT));
|
||||
@@ -1796,9 +1798,12 @@ mod tests {
|
||||
legacy["format"] = Value::from("solidcraft/7");
|
||||
let mut features = legacy["features"].clone();
|
||||
pieces_in_shell_order(&mut features);
|
||||
for f in features.as_array_mut().into_iter().flatten() {
|
||||
f["kernel_piece_order"] = Value::Bool(true);
|
||||
}
|
||||
let params = legacy["params"].clone();
|
||||
let upgraded = read(&legacy.to_string()).unwrap();
|
||||
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
|
||||
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
|
||||
assert_eq!(upgraded, d);
|
||||
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
|
||||
assert_eq!(upgraded_json["features"], features);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! An Align can name planes (`from_plane`, `to_plane`: an origin, construction or offset plane, or
|
||||
//! an explicit one) instead of a point or a face's point and normal, so the bodies follow the plane
|
||||
//! when it moves. Older designs need no transformation: an Align without them keeps the points and
|
||||
//! normals it was saved with. The last release refuses a design saved with this (the newer format
|
||||
//! number).
|
||||
@@ -0,0 +1,34 @@
|
||||
//! The pieces of a split face are numbered by place (#3902): `#1`, `#2`, … follow the spread of
|
||||
//! their centres, not the kernel's face order or the index of the operand face they came from,
|
||||
//! which can change from one evaluation to the next. Older designs' stored `#k` names mean the
|
||||
//! old numbering, so this step marks each of their features `kernel_piece_order`, which keeps
|
||||
//! it for them. Features added later number by place. The last release refuses a design saved
|
||||
//! with this (the newer format number).
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn step(v: &mut Value) -> crate::Result<()> {
|
||||
let Some(Value::Array(features)) = v.get_mut("features") else {
|
||||
return Ok(());
|
||||
};
|
||||
for feature in features {
|
||||
if let Value::Object(o) = feature {
|
||||
o.insert("kernel_piece_order".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn older_features_keep_their_piece_numbering() {
|
||||
let mut v = json!({"features": [{"type": "sketch"}, {"type": "extrude"}]});
|
||||
super::step(&mut v).unwrap();
|
||||
assert!(v["features"].as_array().unwrap().iter().all(|f| f["kernel_piece_order"] == true));
|
||||
let mut empty = json!({});
|
||||
super::step(&mut empty).unwrap();
|
||||
assert_eq!(empty, json!({}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
//! A sheet's perforation field can run its grid from the flat's right edge (`from_right`): the
|
||||
//! field of a mirrored sheet copy, whose holes are the mirror image of its source's. Older designs
|
||||
//! need no transformation: a field without `from_right` runs from the left as before. The last
|
||||
//! release refuses a design saved with this (the newer format number).
|
||||
@@ -17,6 +17,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_kernel::history::{FaceOrigin, FaceProv, History};
|
||||
|
||||
use crate::ModelBody;
|
||||
@@ -44,13 +45,14 @@ pub(crate) struct FaceInfo {
|
||||
|
||||
impl Names {
|
||||
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
|
||||
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
|
||||
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
|
||||
/// by the faces around them (`adjacent`: pairs of faces sharing an edge), then numbered by
|
||||
/// place (`centres`, see [`qualify_at`]).
|
||||
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
|
||||
let named = names
|
||||
.into_iter()
|
||||
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
|
||||
.collect();
|
||||
qualify(named, adjacent)
|
||||
qualify_at(named, adjacent, centres)
|
||||
}
|
||||
|
||||
/// Names given as they are (a moved copy keeps its source's), taken as exact.
|
||||
@@ -95,7 +97,8 @@ pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Opti
|
||||
}
|
||||
}
|
||||
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
|
||||
Some(qualify(named, &adjacency(h)))
|
||||
let centres = || crate::naming::face_centres(b);
|
||||
Some(qualify_at(named, &adjacency(h), by_place(&o.feature, ¢res)))
|
||||
}
|
||||
|
||||
/// Pairs of faces that share an edge, from a history.
|
||||
@@ -140,7 +143,8 @@ impl<'a> OperandNames<'a> {
|
||||
.get_or_init(|| {
|
||||
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
|
||||
let names = crate::naming::geometric_names(body, o, body.body.face_count());
|
||||
Names::unverified(names, &crate::naming::mesh_adjacency(body))
|
||||
let centres = || crate::naming::face_centres(body);
|
||||
Names::unverified(names, &crate::naming::mesh_adjacency(body), by_place(&o.feature, ¢res))
|
||||
}))
|
||||
})
|
||||
.clone();
|
||||
@@ -266,7 +270,32 @@ pub(crate) fn descends(name: &str, stem: &str) -> bool {
|
||||
|
||||
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
|
||||
/// lack (`adjacent`: pairs of faces sharing an edge).
|
||||
#[cfg(test)]
|
||||
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
qualify_at(named, adjacent, None)
|
||||
}
|
||||
|
||||
/// How the pieces `f` splits a face into are numbered: by place (`centres`), else, for a feature
|
||||
/// saved before that (`Feature::kernel_piece_order`), as the build that saved it numbered them.
|
||||
pub(crate) fn by_place<'a>(f: &crate::Feature, centres: &'a dyn Fn() -> Vec<Vec3>) -> Option<&'a dyn Fn() -> Vec<Vec3>> {
|
||||
(!f.kernel_piece_order).then_some(centres)
|
||||
}
|
||||
|
||||
/// [`qualify`], with `centres` giving each face's centre (worked out only when pieces tie): pieces
|
||||
/// that nothing else tells apart are numbered along their spread, not in the kernel's order,
|
||||
/// which can differ from one evaluation to the next (#3902). Without `centres`, in the kernel's
|
||||
/// order and by the faces they came from, as builds before that numbered them.
|
||||
fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
|
||||
let by_place = centres.is_some();
|
||||
let worked_out = std::cell::OnceCell::new();
|
||||
// Tied pieces in the order of their spread; as they are when there are no centres.
|
||||
let in_order = |tied: Vec<usize>| -> Vec<usize> {
|
||||
let at: &Vec<Vec3> = worked_out.get_or_init(|| centres.map(|c| c()).unwrap_or_default());
|
||||
if at.len() != named.len() {
|
||||
return tied;
|
||||
}
|
||||
crate::naming::along_spread(tied.into_iter().filter_map(|i| Some((i, *at.get(i)?))).collect())
|
||||
};
|
||||
let n = named.len();
|
||||
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
|
||||
for &(a, b) in adjacent {
|
||||
@@ -320,8 +349,20 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
let mut tied = tied;
|
||||
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
|
||||
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
|
||||
if distinct {
|
||||
if distinct && !by_place {
|
||||
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
|
||||
} else if distinct {
|
||||
// By what they came from, but not by where it stood in its operand's face order:
|
||||
// an operand that came out of a boolean can list its faces in another order on
|
||||
// the next evaluation (#3902). Pieces of faces of one kind are numbered by place.
|
||||
let mut by_kind: BTreeMap<Vec<FaceOrigin>, Vec<usize>> = BTreeMap::new();
|
||||
for i in tied {
|
||||
let kind = named.get(i).and_then(|x| x.origins.as_deref()).unwrap_or_default().iter().map(|o| without_index(*o)).collect();
|
||||
by_kind.entry(kind).or_default().push(i);
|
||||
}
|
||||
tied = by_kind.into_values().flat_map(&in_order).collect();
|
||||
} else {
|
||||
tied = in_order(tied);
|
||||
}
|
||||
for (k, i) in tied.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*i) {
|
||||
@@ -333,17 +374,17 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A piece's new name can meet a face that already had it: numbered too, never shared.
|
||||
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for name in &names {
|
||||
*seen.entry(name.clone()).or_default() += 1;
|
||||
// A piece's new name can meet a face that already had it: numbered too (along their spread),
|
||||
// never shared.
|
||||
let mut seen: BTreeMap<String, Vec<usize>> = BTreeMap::new();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
seen.entry(name.clone()).or_default().push(i);
|
||||
}
|
||||
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for (i, name) in names.iter_mut().enumerate() {
|
||||
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
|
||||
let k = counter.entry(name.clone()).or_default();
|
||||
*k += 1;
|
||||
*name = format!("{name}#{k}");
|
||||
for (name, same) in seen.into_iter().filter(|(_, same)| same.len() > 1) {
|
||||
for (k, i) in in_order(same).into_iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(i) {
|
||||
*slot = format!("{name}#{}", k + 1);
|
||||
}
|
||||
if let Some(v) = verified.get_mut(i) {
|
||||
*v = false;
|
||||
}
|
||||
@@ -353,6 +394,17 @@ fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
|
||||
Names { faces: Arc::new(names), info }
|
||||
}
|
||||
|
||||
/// `origin` with the face or edge it names left out (its kind and operand kept).
|
||||
fn without_index(origin: FaceOrigin) -> FaceOrigin {
|
||||
match origin {
|
||||
FaceOrigin::Face { operand, .. } => FaceOrigin::Face { operand, face: 0 },
|
||||
FaceOrigin::Edge { operand, .. } => FaceOrigin::Edge { operand, edge: 0 },
|
||||
FaceOrigin::Offset { operand, .. } => FaceOrigin::Offset { operand, face: 0 },
|
||||
// A profile's segments come in the sketch's order.
|
||||
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => origin,
|
||||
}
|
||||
}
|
||||
|
||||
/// How a stored face name matches a current face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Match {
|
||||
|
||||
@@ -57,11 +57,12 @@ pub use assembly::{
|
||||
rigid,
|
||||
};
|
||||
pub use document::*;
|
||||
pub use eval::KeptResults;
|
||||
pub use eval::{
|
||||
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, curve_pick_error,
|
||||
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, 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, suggested_thread, through_all_distance, world_state, world_state_reusing,
|
||||
state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
|
||||
};
|
||||
pub use expr::Expr;
|
||||
pub use names::{BodyName, FeatureName, SketchId};
|
||||
|
||||
+46
-36
@@ -241,13 +241,11 @@ pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
|
||||
.get_or_init(|| {
|
||||
// Exact history needs no mesh; geometry does (its face count is the mesh's).
|
||||
let names = match b.names.origin.as_ref() {
|
||||
Some(o) if o.depth < MAX_DEPTH => b
|
||||
.names
|
||||
.lineage
|
||||
.as_ref()
|
||||
.and_then(|l| crate::history_names::derive(b, o, l))
|
||||
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
|
||||
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
|
||||
Some(o) if o.depth < MAX_DEPTH => b.names.lineage.as_ref().and_then(|l| crate::history_names::derive(b, o, l)).unwrap_or_else(|| {
|
||||
let centres = || face_centres(b);
|
||||
Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b), crate::history_names::by_place(&o.feature, ¢res))
|
||||
}),
|
||||
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[], None),
|
||||
};
|
||||
Arc::new(names)
|
||||
})
|
||||
@@ -519,35 +517,8 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
let ps = pts.get(j).cloned().unwrap_or_default();
|
||||
ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)
|
||||
};
|
||||
let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect();
|
||||
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
|
||||
let key = |c: Vec3| {
|
||||
if ext.x >= ext.y && ext.x >= ext.z {
|
||||
(c.x, c.y, c.z)
|
||||
} else if ext.y >= ext.z {
|
||||
(c.y, c.z, c.x)
|
||||
} else {
|
||||
(c.z, c.x, c.y)
|
||||
}
|
||||
};
|
||||
let mut order = cs.clone();
|
||||
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
|
||||
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
|
||||
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
|
||||
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
|
||||
// keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic.
|
||||
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
|
||||
let bucket = |c: Vec3| {
|
||||
let k = key(c);
|
||||
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
|
||||
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
|
||||
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
|
||||
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
|
||||
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
|
||||
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
|
||||
};
|
||||
order.sort_by_key(|a| bucket(a.1));
|
||||
for (k, (j, _)) in order.iter().enumerate() {
|
||||
let order = along_spread(same.iter().map(|j| (*j, centre(*j))).collect());
|
||||
for (k, j) in order.iter().enumerate() {
|
||||
if let Some(slot) = names.get_mut(*j) {
|
||||
*slot = format!("{n}{mark}{}", k + 1);
|
||||
}
|
||||
@@ -555,6 +526,45 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The items of `cs` (index, centre) ordered along the longest axis of their centres' spread,
|
||||
/// then the next: how the pieces of one face or edge are numbered, the same on every run
|
||||
/// whatever order the kernel gives them in.
|
||||
pub(crate) fn along_spread(cs: Vec<(usize, Vec3)>) -> Vec<usize> {
|
||||
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
|
||||
let key = |c: Vec3| {
|
||||
if ext.x >= ext.y && ext.x >= ext.z {
|
||||
(c.x, c.y, c.z)
|
||||
} else if ext.y >= ext.z {
|
||||
(c.y, c.z, c.x)
|
||||
} else {
|
||||
(c.z, c.x, c.y)
|
||||
}
|
||||
};
|
||||
let mut order = cs;
|
||||
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
|
||||
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
|
||||
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
|
||||
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
|
||||
// keeps its number across runs (solidcraft#569).
|
||||
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
|
||||
let bucket = |c: Vec3| {
|
||||
let k = key(c);
|
||||
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
|
||||
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
|
||||
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
|
||||
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
|
||||
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
|
||||
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
|
||||
};
|
||||
order.sort_by_key(|a| bucket(a.1));
|
||||
order.into_iter().map(|(j, _)| j).collect()
|
||||
}
|
||||
|
||||
/// Each face's centre: the mean of the sampled centres of its triangles.
|
||||
pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
|
||||
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
|
||||
}
|
||||
|
||||
/// The name of a face new to `f`.
|
||||
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String {
|
||||
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
|
||||
|
||||
+105
-3
@@ -541,6 +541,10 @@ pub struct SheetPerforation {
|
||||
pub pitch: f64,
|
||||
pub margin: f64,
|
||||
pub square: bool,
|
||||
/// The grid runs from the flat's right edge instead of its left: the field of a mirrored
|
||||
/// copy, whose holes are the mirror image of its source's.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub from_right: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -1992,8 +1996,11 @@ impl SheetBody {
|
||||
if y > hi.y - clear + 1e-9 {
|
||||
break;
|
||||
}
|
||||
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
|
||||
while x <= hi.x - clear + 1e-9 {
|
||||
let shift = if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
|
||||
// Steps across the row from its first hole; a mirrored field counts from the right.
|
||||
let mut step = 0.0;
|
||||
while lo.x + clear + shift + step <= hi.x - clear + 1e-9 {
|
||||
let x = if p.from_right { hi.x - clear - shift - step } else { lo.x + clear + shift + step };
|
||||
let c = Vec2::new(x, y);
|
||||
let (i, j) = idx(x, y);
|
||||
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
|
||||
@@ -2003,7 +2010,7 @@ impl SheetBody {
|
||||
{
|
||||
out.push(c);
|
||||
}
|
||||
x += p.pitch;
|
||||
step += p.pitch;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
@@ -4355,6 +4362,101 @@ impl Letters {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat point mirrored across the flat frame's y axis (x → −x).
|
||||
fn flip_x(p: Vec2) -> Vec2 {
|
||||
Vec2::new(-p.x, p.y)
|
||||
}
|
||||
|
||||
/// A loop mirrored across the flat frame's y axis, exactly (arcs stay arcs, free-form curves
|
||||
/// follow their control points), and run backwards so it keeps its sense: an outer loop stays
|
||||
/// counter-clockwise, a hole clockwise.
|
||||
fn flip_x_loop(l: &Loop2) -> Loop2 {
|
||||
let segs = l
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }
|
||||
}
|
||||
other => map_controls(&other, flip_x),
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.reversed()
|
||||
}
|
||||
|
||||
fn flip_x_region(r: &Region2) -> Region2 {
|
||||
Region2 { outer: flip_x_loop(&r.outer), holes: r.holes.iter().map(flip_x_loop).collect() }
|
||||
}
|
||||
|
||||
impl SheetBody {
|
||||
/// The sheet model of a copy of this body moved by `m` and named `body`: a rigid motion
|
||||
/// moves its frame; a mirror moves its frame too and mirrors everything in the flat, so the
|
||||
/// copy is the opposite hand, with the same thickness side, bends, K-factor and flat
|
||||
/// length. `None` when `m` is not a rigid motion or a mirror (a scaled copy is no sheet).
|
||||
pub fn copied(&self, body: &str, m: &Mat) -> Option<SheetBody> {
|
||||
let axis = |i: usize| m.get(i).map(|c| Vec3::new(c[0], c[1], c[2]));
|
||||
let (x, y, z) = (axis(0)?, axis(1)?, axis(2)?);
|
||||
let unit = |v: Vec3| (v.dot(v) - 1.0).abs() < 1e-6;
|
||||
let isometry = m.iter().flatten().all(|v| v.is_finite())
|
||||
&& unit(x)
|
||||
&& unit(y)
|
||||
&& unit(z)
|
||||
&& x.dot(y).abs() < 1e-6
|
||||
&& x.dot(z).abs() < 1e-6
|
||||
&& y.dot(z).abs() < 1e-6;
|
||||
if !isometry {
|
||||
return None;
|
||||
}
|
||||
let (origin, fx, fy) = (apply_point(m, self.frame.origin), apply_vector(m, self.frame.x), apply_vector(m, self.frame.y));
|
||||
let mut out = self.clone();
|
||||
out.body = body.to_string();
|
||||
if x.cross(y).dot(z) > 0.0 {
|
||||
out.frame = Plane::new(origin, fx, fy)?;
|
||||
} else {
|
||||
// The mirror of flat point (u, v, w) is the point (−u, v, w) of the frame with its x
|
||||
// axis turned round: that frame's normal is the mirrored normal, so the material
|
||||
// stays on the same side of the mirrored base face.
|
||||
out.frame = Plane::new(origin, fx * -1.0, fy)?;
|
||||
out.flip_flat();
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Everything in flat coordinates mirrored across the flat's y axis (x → −x).
|
||||
fn flip_flat(&mut self) {
|
||||
self.base = flip_x_region(&self.base);
|
||||
for f in &mut self.flanges {
|
||||
// The bend line runs the other way, from its far end, so the flange still lies on
|
||||
// the same side of it (`n` turned as `d` is): its ends swap.
|
||||
f.p = flip_x(f.p + f.d * f.len);
|
||||
f.d = Vec2::new(f.d.x, -f.d.y);
|
||||
f.n = flip_x(f.n);
|
||||
f.ext = (f.ext.1, f.ext.0);
|
||||
f.slope = (f.slope.1, f.slope.0);
|
||||
f.tear = (f.tear.1, f.tear.0);
|
||||
if let Some(shape) = &mut f.shape {
|
||||
*shape = shape.iter().rev().map(|p| flip_x(*p)).collect();
|
||||
}
|
||||
if let Some(exact) = &mut f.exact {
|
||||
*exact = flip_x_loop(exact);
|
||||
}
|
||||
}
|
||||
for r in self.holes.iter_mut().chain(self.labels.iter_mut()).chain(self.pockets.iter_mut().map(|p| &mut p.region)) {
|
||||
*r = flip_x_region(r);
|
||||
}
|
||||
for b in &mut self.bores {
|
||||
b.base.x = -b.base.x;
|
||||
b.axis.x = -b.axis.x;
|
||||
}
|
||||
for p in &mut self.perforations {
|
||||
p.from_right = !p.from_right;
|
||||
}
|
||||
for (_, a, b) in &mut self.base_curves {
|
||||
(*a, *b) = (flip_x(*b), flip_x(*a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?);
|
||||
|
||||
@@ -103,6 +103,28 @@ fn set_back_reports_restored_features() {
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
|
||||
}
|
||||
|
||||
/// #1989: what an evaluation ahead on a copy of the model computed is kept for the run that
|
||||
/// follows on the model itself, which finds it there even once the evaluation cache has dropped
|
||||
/// it (the cache keeps only a few states of a design with large bodies). Copies keep nothing.
|
||||
#[test]
|
||||
fn results_kept_ahead_outlive_the_cache() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
let kept = std::sync::Arc::new(KeptResults::default());
|
||||
let mut ahead = m.clone();
|
||||
ahead.keep(Some(kept.clone()));
|
||||
assert!(ahead.clone().kept().is_none(), "a copy keeps nothing");
|
||||
ahead.evaluate(&doc);
|
||||
assert_eq!((ahead.last_recomputed, kept.len()), (2, 2));
|
||||
*m.cache.lock().unwrap() = Default::default();
|
||||
m.keep(Some(kept));
|
||||
m.evaluate(&doc);
|
||||
assert_eq!((m.last_recomputed, m.last_restored), (0, 2), "nothing computed again");
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
|
||||
@@ -1150,3 +1150,76 @@ fn joined_loft_frames_reference_mesh_covers_each_face_once() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The block of #2040 with `extra` run after it.
|
||||
fn rounded_block_and(extra: Value) -> Session {
|
||||
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
|
||||
script["commands"].as_array_mut().unwrap().push(extra);
|
||||
replay(&script.to_string())
|
||||
}
|
||||
|
||||
/// #2040: the slanted face of a block whose ends fillets round, extruded as a join. Its outline's
|
||||
/// quarter-ellipses come into the face's sketch as splines a hair off the body's edges, and the
|
||||
/// join failed with "boolean Union: no result"; it now sweeps the body's own face. It adds the
|
||||
/// face's area times its height: a 10 x 12 face less four corners of (1 - pi/4) r * r*sqrt(2).
|
||||
#[test]
|
||||
fn extrude_join_from_a_face_rounded_by_fillets() {
|
||||
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
|
||||
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
|
||||
let r = 3.8_f64;
|
||||
let area = 10.0 * 12.0 - 4.0 * (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 2f64.sqrt();
|
||||
let joined = rounded_block_and(json!({"command": "solid.extrude", "params": {"face": at, "distance": 5, "operation": "join"}}));
|
||||
let bodies = &joined.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1, "the join makes no second body");
|
||||
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
|
||||
let v = volume(&joined, "Body1");
|
||||
assert!((v - (before + area * 5.0)).abs() < 0.05, "join {v} expected {}", before + area * 5.0);
|
||||
}
|
||||
|
||||
/// #2040: a cut 3 mm into the slanted face of the block rounded along its top only (its bottom
|
||||
/// edge left sharp). The walls from the bottom edge go into the body and the others only touch
|
||||
/// it, along edges with curved faces beside them: the cut failed with "boolean Cut: no result".
|
||||
/// In the face's plane the body's section at depth t is the block's section (10 wide, h high, its
|
||||
/// top corners rounded) moved down the face by t: the cut takes
|
||||
/// √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
|
||||
#[test]
|
||||
fn extrude_cut_into_a_face_beside_rounds() {
|
||||
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
|
||||
let top_only = |extra: Option<Value>| {
|
||||
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
|
||||
let commands = script["commands"].as_array_mut().unwrap();
|
||||
let fillet = commands.last_mut().unwrap();
|
||||
fillet["params"]["edges"] = json!([[10, -10, 8.485281374238571], [10, 0, 8.485281374238571]]);
|
||||
commands.extend(extra);
|
||||
replay(&script.to_string())
|
||||
};
|
||||
let before = volume(&top_only(None), "Body1");
|
||||
let cut = top_only(Some(json!({"command": "solid.extrude", "params": {"face": at, "distance": 3, "direction": "negative", "operation": "cut"}})));
|
||||
let bodies = &cut.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1);
|
||||
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
|
||||
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
|
||||
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
|
||||
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
|
||||
let v = before - volume(&cut, "Body1");
|
||||
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
|
||||
}
|
||||
|
||||
/// #2040: a cut 2 mm into the top face of the block rounded at its ends, the strip 10 − 2r wide
|
||||
/// between the rounds. Below the face the rounds stand out past its outline, so a slab over the
|
||||
/// whole body would take them too: the cut takes only what lies over the face, at height z from
|
||||
/// x = 0 to the slanted face at x = x0 + z.
|
||||
#[test]
|
||||
fn extrude_cut_into_a_rounded_block_top() {
|
||||
let (h, r, x0, d) = (8.485281374238571_f64, 3.8_f64, 11.514718625761429_f64, 2.0_f64);
|
||||
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
|
||||
let cut = rounded_block_and(
|
||||
json!({"command": "solid.extrude", "params": {"face": [5, -5, h], "distance": d, "direction": "negative", "operation": "cut"}}),
|
||||
);
|
||||
let bodies = &cut.model.state().bodies;
|
||||
assert_eq!(bodies.len(), 1);
|
||||
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
|
||||
let taken = (10.0 - 2.0 * r) * (x0 * d + (h * h - (h - d) * (h - d)) / 2.0);
|
||||
let v = before - volume(&cut, "Body1");
|
||||
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
|
||||
}
|
||||
|
||||
@@ -559,18 +559,24 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
|
||||
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
|
||||
};
|
||||
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
|
||||
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
|
||||
// Bodies later features name after this one (`Top (1)`, a split's other piece) keep their
|
||||
// names too: they would otherwise follow it, and break what names them (#3510).
|
||||
let derived = format!("{old} (");
|
||||
let at = s.doc.features.iter().position(|f| f.id == fid);
|
||||
let counts = at.and_then(|at| s.doc.features.get(at)).is_some_and(|f| counted(f, k));
|
||||
let later: Vec<(u64, Vec<String>)> = match at {
|
||||
Some(at) => s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.skip(at + 1)
|
||||
.filter_map(|f| {
|
||||
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
|
||||
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
|
||||
let keep = (counts && (0..made.len()).any(|i| counted(f, i))) || made.iter().any(|n| n.starts_with(&derived));
|
||||
keep.then_some((f.id, made))
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let d = s.doc_mut();
|
||||
for (id, made) in &later {
|
||||
@@ -771,6 +777,26 @@ mod tests {
|
||||
assert_eq!(names(&s)[1], b);
|
||||
}
|
||||
|
||||
/// #3510: each piece of a split is renamed on its own. Renaming the split body leaves the
|
||||
/// other piece, and the features that use it, as they were.
|
||||
#[test]
|
||||
fn split_pieces_are_renamed_on_their_own() {
|
||||
let ok = |s: &Session| s.model.results.iter().all(|r| r.error.is_none());
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, -5], "body_name": "Top"}));
|
||||
run(&mut s, "solid.split_body", json!({"body": "Top", "plane": "XY"}));
|
||||
assert_eq!(names(&s), vec!["Top", "Top (1)"]);
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Top (1)"], "translate": [0, 0, -5]}));
|
||||
run(&mut s, "body.rename", json!({"body": "Top", "name": "Upper"}));
|
||||
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
|
||||
assert!(ok(&s), "the move still finds the other piece");
|
||||
run(&mut s, "body.rename", json!({"body": "Top (1)", "name": "Lower"}));
|
||||
assert_eq!(names(&s), vec!["Upper", "Lower"]);
|
||||
assert!(ok(&s), "the move follows the renamed piece");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
|
||||
}
|
||||
|
||||
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
|
||||
/// follows them, as it follows the names inside the kind (a combine's target and tools).
|
||||
#[test]
|
||||
|
||||
@@ -111,8 +111,9 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
|
||||
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
legacy["format"] = json!("solidcraft/9");
|
||||
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
|
||||
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order.
|
||||
let anchored = solidcraft_doc::Feature { shell_piece_order: true, ..anchored };
|
||||
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order,
|
||||
// and numbering the pieces of its faces as that build did (#3902).
|
||||
let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, ..anchored };
|
||||
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
|
||||
// Names saved before format 14 keep naming the face they named, marked so they are found as
|
||||
// the build that saved them found them.
|
||||
|
||||
@@ -63,14 +63,30 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
|
||||
/// The point is in the frame of the command's other picks: the component's own when
|
||||
/// they were mapped into it, else the world's.
|
||||
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
|
||||
match face_ref_form(v).map_err(|why| bad(cmd, why))? {
|
||||
FaceRefForm::Point(p) => Ok(p),
|
||||
FaceRefForm::Name(name) => {
|
||||
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
|
||||
solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face reference as given, before it is looked up: see [`face_ref`].
|
||||
pub(crate) enum FaceRefForm<'a> {
|
||||
Point(Vec3),
|
||||
Name(&'a str),
|
||||
}
|
||||
|
||||
/// What [`face_ref`] reads `v` as, without the design: a point, or a name of 1…512 bytes.
|
||||
pub(crate) fn face_ref_form(v: &Value) -> std::result::Result<FaceRefForm<'_>, &'static str> {
|
||||
if let Some(name) = v.as_str() {
|
||||
if name.is_empty() || name.len() > 512 {
|
||||
return Err(bad(cmd, "a face name is 1…512 characters"));
|
||||
return Err("a face name is 1…512 characters");
|
||||
}
|
||||
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
|
||||
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
|
||||
return Ok(FaceRefForm::Name(name));
|
||||
}
|
||||
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
|
||||
vec3(v).or_else(|| v.get("point").and_then(vec3)).map(FaceRefForm::Point).ok_or("a face is a point [x, y, z] on it, or its name from model.faces")
|
||||
}
|
||||
|
||||
/// The body face under a point: (mesh, face index, distance, body).
|
||||
|
||||
@@ -11,7 +11,8 @@ use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
|
||||
use crate::schema::{BoxParams, ExtrudeParams, FeatureResult, TypedContract, output_schema_for};
|
||||
use crate::schema::features::{BoxParams, ExtrudeParams, FeatureResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
|
||||
@@ -67,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
|
||||
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
|
||||
CommandSpec::new("solid.pipe", "Pipe", pipe)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pipe")
|
||||
@@ -91,7 +92,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.thread", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body"),
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
|
||||
CommandSpec::new("solid.shell", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -204,13 +205,16 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
|
||||
// Match the evaluator's implicit participants: a grouped cut from an empty
|
||||
// assembly component considers every body; otherwise keep the active scope.
|
||||
// Explicit participants override either default.
|
||||
// A component with no body of its own works on those of the components inside it (#3223).
|
||||
let parts = string_list(p, "participants");
|
||||
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
|
||||
let bodies: Vec<_> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
|
||||
.collect();
|
||||
let bodies: Vec<_> = if !parts.is_empty() {
|
||||
st.bodies.iter().filter(|b| parts.contains(&b.name)).collect()
|
||||
} else if implicit_all {
|
||||
st.bodies.iter().collect()
|
||||
} else {
|
||||
active_scope(s, &st)
|
||||
};
|
||||
if bodies.is_empty() {
|
||||
return Some("new");
|
||||
}
|
||||
@@ -241,11 +245,7 @@ pub(super) fn operation(p: &Value, cmd: &str) -> Result<Operation> {
|
||||
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
|
||||
pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
if let Some(v) = p.get("sketch") {
|
||||
let key = match v {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
|
||||
};
|
||||
let key = sketch_key(Some(v)).ok_or_else(|| bad(cmd, "`sketch` must be an id or name"))?;
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
return match f.kind {
|
||||
FeatureKind::Sketch { .. } => Ok(f.id),
|
||||
@@ -497,11 +497,12 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
}
|
||||
// A cut or intersect made where the active component has no body of its own (a sketch in
|
||||
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
|
||||
// takes part in every body instead, which it changes only where it meets them.
|
||||
// takes part in every body instead, which it changes only where it meets them. An extrude's
|
||||
// own operation counts too: an automatic one that came out as a cut (#3223).
|
||||
let participants = if participants.is_empty()
|
||||
&& !primitive
|
||||
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|
||||
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|
||||
|| matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|
||||
&& {
|
||||
let st = s.model.state();
|
||||
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
|
||||
@@ -655,14 +656,40 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
|
||||
/// it is a body of the active component.
|
||||
/// it is one the active component works on ([`active_scope`]).
|
||||
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
|
||||
let FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch)?.kind else {
|
||||
return None;
|
||||
};
|
||||
let st = s.model.state();
|
||||
let body = st.body(&anchor.target.body)?;
|
||||
(s.doc.model_body_component(body) == s.active_component).then(|| body.name.clone())
|
||||
active_scope(s, &st).into_iter().find(|b| b.name == anchor.target.body).map(|b| b.name.clone())
|
||||
}
|
||||
|
||||
/// The bodies a feature of the active component works on when it names none: the component's
|
||||
/// own. A component with no body of its own (the root of an opened STEP assembly, whose parts
|
||||
/// are components inside it) works on the bodies of the components inside it whose geometry is
|
||||
/// in its own frame, as an import places it: a profile sketched on such a part's face meets the
|
||||
/// part where it is shown (#3223).
|
||||
fn active_scope<'a>(s: &Session, st: &'a solidcraft_doc::ModelState) -> Vec<&'a solidcraft_doc::ModelBody> {
|
||||
let active = s.active_component;
|
||||
let own: Vec<_> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == active).collect();
|
||||
if !own.is_empty() {
|
||||
return own;
|
||||
}
|
||||
let Some(into_active) = solidcraft_doc::mat_inverse(&s.doc.component_transform(active)) else { return own };
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
let comp = s.doc.model_body_component(b);
|
||||
if !s.doc.component_within(comp, active) {
|
||||
return false;
|
||||
}
|
||||
let frame = s.doc.component_transform(comp);
|
||||
let world = s.doc.model_body_offset(b).map_or(frame, |offset| solidcraft_doc::mat_mul(&frame, offset));
|
||||
let rel = solidcraft_doc::mat_mul(&into_active, &world);
|
||||
rel.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-9)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
|
||||
@@ -970,45 +997,64 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
|
||||
let mut out = Vec::new();
|
||||
for e in a {
|
||||
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut nearest = f64::INFINITY;
|
||||
let on_edge = bodies.iter().any(|(size, edges)| {
|
||||
let d = edges
|
||||
.iter()
|
||||
.flat_map(|e| e.points.windows(2))
|
||||
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
d <= size * 0.05 + 1e-3
|
||||
});
|
||||
if !on_edge && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
|
||||
EdgeRefForm::Point(pt) => pt,
|
||||
EdgeRefForm::Index { body, index } => {
|
||||
let idx = index as usize;
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
continue;
|
||||
}
|
||||
out.push(pt);
|
||||
continue;
|
||||
};
|
||||
let bodies = candidates.get_or_insert_with(|| {
|
||||
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
|
||||
st.bodies
|
||||
.iter()
|
||||
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
|
||||
.filter_map(|b| {
|
||||
let size = b.body.size();
|
||||
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let mut nearest = f64::INFINITY;
|
||||
let on_edge = bodies.iter().any(|(size, edges)| {
|
||||
let d = edges
|
||||
.iter()
|
||||
.flat_map(|e| e.points.windows(2))
|
||||
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
nearest = nearest.min(d);
|
||||
d <= size * 0.05 + 1e-3
|
||||
});
|
||||
if !on_edge && !bodies.is_empty() {
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
|
||||
}
|
||||
let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?;
|
||||
let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize;
|
||||
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
let edges = b.body.edges(tol)?;
|
||||
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
|
||||
out.push(pt);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An edge reference as given, before it is looked up: see [`edge_points`].
|
||||
pub(crate) enum EdgeRefForm<'a> {
|
||||
Point(Vec3),
|
||||
Index { body: &'a str, index: u64 },
|
||||
}
|
||||
|
||||
/// What [`edge_points`] reads one edge as, without the design: a point on it (`[x, y, z]` or
|
||||
/// `{point}`), else `{body, index}`.
|
||||
pub(crate) fn edge_ref_form(e: &Value) -> std::result::Result<EdgeRefForm<'_>, &'static str> {
|
||||
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
|
||||
return Ok(EdgeRefForm::Point(pt));
|
||||
}
|
||||
let body = e.get("body").and_then(Value::as_str).ok_or("an edge is a point [x,y,z], {point} or {body, index}")?;
|
||||
let index = e.get("index").and_then(Value::as_u64).ok_or("edge `index` must be a number")?;
|
||||
Ok(EdgeRefForm::Index { body, index })
|
||||
}
|
||||
|
||||
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
|
||||
/// edge is rounded (a fillet).
|
||||
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1807,15 +1853,25 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
|
||||
None => None,
|
||||
};
|
||||
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
|
||||
// that body: the pick is already in that body's frame, and the component's own bodies alone
|
||||
// would leave nothing to cut.
|
||||
let mut p = p.clone();
|
||||
if points.is_none()
|
||||
&& p.get("participants").is_none()
|
||||
&& let Some(body) = body_of_other_component_at(s, position)
|
||||
{
|
||||
p["participants"] = json!([body]);
|
||||
let targets = string_list(&p, "targets");
|
||||
if p.get("participants").is_none() {
|
||||
// The bodies to drill, as `targets` (like an extrude's): its participants.
|
||||
if !targets.is_empty() {
|
||||
p["participants"] = json!(targets);
|
||||
} else if let Some(body) = points.as_ref().filter(|(sp, _, _)| !sp.single).and_then(|(sp, _, _)| sketch_face_body(s, sp.sketch.0)) {
|
||||
// Holes at the points of a sketch on a body's face drill that body (#2882): a body
|
||||
// further along, past the face, doesn't take them. Stored, so recomputes and
|
||||
// reopening keep the same scope.
|
||||
p["participants"] = json!([body]);
|
||||
} else if points.is_none()
|
||||
&& let Some(body) = body_of_other_component_at(s, position)
|
||||
{
|
||||
// A hole picked on a body of another component (a moved one, shown where it is
|
||||
// placed) cuts that body: the pick is already in that body's frame, and the
|
||||
// component's own bodies alone would leave nothing to cut.
|
||||
p["participants"] = json!([body]);
|
||||
}
|
||||
}
|
||||
add_feature(
|
||||
s,
|
||||
@@ -1920,17 +1976,22 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
|
||||
let key = match v {
|
||||
Some(Value::Number(n)) => n.to_string(),
|
||||
Some(Value::String(x)) => x.clone(),
|
||||
_ => return Err(bad(cmd, format!("`{what}` must be a sketch id or name"))),
|
||||
};
|
||||
let key = sketch_key(v).ok_or_else(|| bad(cmd, format!("`{what}` must be a sketch id or name")))?;
|
||||
match s.doc.find_feature(&key) {
|
||||
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => Ok(f.id),
|
||||
_ => Err(bad(cmd, format!("no sketch `{key}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The key [`sketch_id`] and [`feature_sketch`] look a sketch up by: an id (a number) or a name.
|
||||
pub(crate) fn sketch_key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
Some(Value::String(x)) => Some(x.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.sweep";
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.align", "Align", align)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("align")
|
||||
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
|
||||
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool"),
|
||||
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
|
||||
];
|
||||
|
||||
@@ -385,23 +385,46 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option<Vec3>)> {
|
||||
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
|
||||
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
|
||||
// a face, its normal.
|
||||
let plane = |s: &Session, key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
|
||||
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
|
||||
};
|
||||
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
|
||||
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
|
||||
if plane.is_some() {
|
||||
if p.get(pk).is_some() || p.get(fk).is_some() {
|
||||
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
|
||||
}
|
||||
// Worked out from the plane when the feature is evaluated.
|
||||
return Ok((Vec3::ZERO, None));
|
||||
}
|
||||
if let Some(fp) = p.get(fk).and_then(vec3) {
|
||||
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
|
||||
return Ok((fp, Some(n)));
|
||||
}
|
||||
// The origin point.
|
||||
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
|
||||
return Ok((Vec3::ZERO, None));
|
||||
}
|
||||
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
|
||||
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
|
||||
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
|
||||
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
|
||||
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
|
||||
}
|
||||
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
|
||||
p.get(pk)
|
||||
.and_then(vec3)
|
||||
.map(|v| (v, None))
|
||||
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
|
||||
};
|
||||
let (from, from_normal) = pick(s, "from", "from_face")?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face")?;
|
||||
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
|
||||
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
|
||||
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
|
||||
let (from_normal, to_normal) = match (from_normal, to_normal) {
|
||||
(Some(a), Some(b)) => (Some(a), Some(b)),
|
||||
(a, b) if from_plane.is_some() || to_plane.is_some() => (a, b),
|
||||
_ => (None, None),
|
||||
};
|
||||
let flip = bool_(p, "flip").unwrap_or(false);
|
||||
@@ -415,6 +438,8 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
from_normal: from_normal.map(Dir),
|
||||
to_normal: to_normal.map(Dir),
|
||||
flip,
|
||||
from_plane,
|
||||
to_plane,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -540,6 +540,57 @@ fn replace_face_align_remove() {
|
||||
assert_eq!(measure(&mut s)["body_count"], 1);
|
||||
}
|
||||
|
||||
/// The z range and the x minimum of body `name`.
|
||||
fn z_x(s: &mut Session, name: &str) -> (f64, f64, f64) {
|
||||
let m = measure(s);
|
||||
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == name).unwrap().clone();
|
||||
let g = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
|
||||
(g("min", 2), g("max", 2), g("min", 0))
|
||||
}
|
||||
|
||||
/// Align onto planes and the origin: a face goes flush onto an origin plane (turning to meet it),
|
||||
/// onto an offset plane, a vertex onto the origin point, and a plane the bodies are aligned to
|
||||
/// is followed when it moves.
|
||||
#[test]
|
||||
fn align_takes_planes_and_the_origin() {
|
||||
let boxed = || {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 20], "body_name": "C"}));
|
||||
s
|
||||
};
|
||||
// The bottom face onto XY: it already faces down, so it only drops; nothing slides across.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "XY"}));
|
||||
let (lo, hi, x) = z_x(&mut s, "C");
|
||||
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6 && (x - 50.0).abs() < 1e-6, "{lo} {hi} {x}");
|
||||
// The top face onto XY: the body turns over (the face now faces down) and sits on the plane.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 30], "to_plane": "XY"}));
|
||||
let (lo, hi, _) = z_x(&mut s, "C");
|
||||
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{lo} {hi}");
|
||||
assert!(measure(&mut s)["bodies"][0]["bbox"]["max"][2].as_f64().is_some());
|
||||
// A vertex onto the origin point.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from": {"snap": "vertex", "at": [50, 0, 20]}, "to": "origin"}));
|
||||
let (lo, _, x) = z_x(&mut s, "C");
|
||||
assert!(lo.abs() < 1e-6 && x.abs() < 1e-6, "{lo} {x}");
|
||||
// Onto an offset plane, and the bodies follow it when its distance changes.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "parameters.add", json!({"name": "lift", "expression": "30 mm"}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "lift", "name": "Shelf"}));
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "Shelf"}));
|
||||
assert!((z_x(&mut s, "C").0 - 30.0).abs() < 1e-6);
|
||||
run(&mut s, "parameters.change", json!({"name": "lift", "expression": "45 mm"}));
|
||||
assert!((z_x(&mut s, "C").0 - 45.0).abs() < 1e-6, "the bodies follow the plane");
|
||||
// A plane as the source: the bodies move with it onto a point.
|
||||
let mut s = boxed();
|
||||
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_plane": "XY", "to": [0, 0, 7]}));
|
||||
assert!((z_x(&mut s, "C").0 - 27.0).abs() < 1e-6);
|
||||
// One side is a plane or a point, not both; an unknown plane is refused.
|
||||
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 7]})).is_err());
|
||||
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from": [0, 0, 0], "to_plane": "Nowhere"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coil_and_loft_to_a_point() {
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("open")
|
||||
.key("Ctrl+O")
|
||||
.noundo()
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
|
||||
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
|
||||
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
@@ -89,9 +89,10 @@ fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
|
||||
};
|
||||
let names = match p.get("names") {
|
||||
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
|
||||
Some(v) => {
|
||||
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
|
||||
}
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(solidcraft_io::StepNames::parse)
|
||||
.ok_or_else(|| bad(cmd, "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""))?,
|
||||
};
|
||||
Ok(StepOptions { y_up, names })
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&DESCRIBE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::DescribeResult>)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::inspect::DescribeResult>)
|
||||
.params("command: a command id. Returns {id, label, params, input, output} (and param_warnings in the warn parameter-check mode): the command's parameter documentation and, once the command has a typed contract, the JSON Schemas (2020-12) of its parameters and of a successful result (null otherwise). Read-only."),
|
||||
];
|
||||
|
||||
@@ -326,13 +326,13 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"body": b.name, "faces": faces}))
|
||||
}
|
||||
|
||||
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::DescribeParams> = crate::schema::TypedContract::new();
|
||||
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::inspect::DescribeParams> = crate::schema::TypedContract::new();
|
||||
|
||||
/// A command's prose documentation and, for converted commands, its schemas.
|
||||
fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = p.get("command").and_then(Value::as_str).ok_or_else(|| crate::params::bad("engine.describe", "`command` must be a command id"))?;
|
||||
let spec = super::find_command(id).ok_or_else(|| crate::EngineError::UnknownCommand(id.to_string()))?;
|
||||
let reply = crate::schema::DescribeResult {
|
||||
let reply = crate::schema::inspect::DescribeResult {
|
||||
id: spec.id.to_string(),
|
||||
label: spec.label.to_string(),
|
||||
params: spec.params.to_string(),
|
||||
|
||||
@@ -35,7 +35,9 @@ mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
mod features_more;
|
||||
pub(crate) use face::{FaceRefForm, face_ref_form};
|
||||
pub(crate) use features::profiles as feature_profiles;
|
||||
pub(crate) use features::{EdgeRefForm, edge_ref_form, sketch_key};
|
||||
pub use features::{auto_operation, extrude_reach};
|
||||
pub use sketch_modify::trim_piece;
|
||||
mod component_link;
|
||||
|
||||
@@ -616,6 +616,61 @@ fn matrix_move_turns_a_quarter_and_moves() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3223, #2872: a STEP assembly whose part sits two levels down, each level placed, opened and
|
||||
/// stood up. With the imported root active, as Open leaves it, a circle sketched on the part's
|
||||
/// top face cuts it through, reads as a Cut dragged in and a Join dragged out, and joins it in
|
||||
/// one piece instead of starting a body of the root's own.
|
||||
#[test]
|
||||
fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-nested-stood-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("nested.step");
|
||||
let mut src = Session::default();
|
||||
let (_, carrier) = created(&mut src, "Carrier");
|
||||
let (part, nested) = created(&mut src, "Nested Part");
|
||||
run(&mut src, "solid.box", json!({"length": 40, "width": 24, "height": 10, "body_name": "NestedBlock"}));
|
||||
run(&mut src, "occurrence.move", json!({"occurrence": carrier, "translate": [12, 7, 5], "axis": [0, 0, 1], "angle": "30 deg"}));
|
||||
run(&mut src, "occurrence.move", json!({"occurrence": nested, "translate": [3, 11, 2], "axis": [0, 1, 0], "angle": "20 deg"}));
|
||||
// The middle of the top face, in the file's frame: the imported root's.
|
||||
let top = solidcraft_doc::apply_point(&src.doc.component_transform(part), solidcraft_geom::Vec3::new(20.0, 12.0, 10.0));
|
||||
run(&mut src, "file.export", json!({"path": path, "format": "step"}));
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
|
||||
let centre = s
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(top)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"));
|
||||
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"));
|
||||
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
let block = 40.0 * 24.0 * 10.0;
|
||||
let hole = std::f64::consts::PI * 3.0 * 3.0;
|
||||
|
||||
// The circle is measured as faceted: within a thousandth of what it removes or adds.
|
||||
for operation in ["cut", "auto"] {
|
||||
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
|
||||
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{operation}: {}", volume(&mut s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
|
||||
let st = s.model.state();
|
||||
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"]);
|
||||
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1));
|
||||
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "join: {}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
|
||||
/// is refused with the reason, and the occurrence stays where it was.
|
||||
#[test]
|
||||
@@ -662,3 +717,75 @@ fn matrix_move_orthonormalises_rounding_and_refuses_what_is_not_rigid() {
|
||||
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform, before);
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": rounded, "tolerance": 1e-3}));
|
||||
}
|
||||
|
||||
/// #3383: a combine across components meets the tool where it is shown, not where its numbers
|
||||
/// would be in the target's own frame. The plate in a component turned a quarter about X sits at
|
||||
/// y -10..0, z -20..20; the boss in the root overlaps it by 10 x 5 x 5. The reverse (a peg in the
|
||||
/// turned component cut from a root plate) removes 10 x 10 x 5 of it, not 10 x 10 x 10.
|
||||
#[test]
|
||||
fn combine_across_a_turned_occurrence_meets_the_tool_where_it_is_shown() {
|
||||
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
|
||||
let turned = |s: &mut Session, body: Value| {
|
||||
created(s, "Part");
|
||||
run(s, "solid.box", body);
|
||||
run(s, "component.activate", json!({"component": "root"}));
|
||||
run(s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [1, 0, 0], "angle": "90 deg"}));
|
||||
run(s, "component.capture_position", json!({}));
|
||||
};
|
||||
let mut s = Session::default();
|
||||
turned(&mut s, json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "body_name": "Plate"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, -25], "operation": "new", "body_name": "Boss"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Plate", "tools": ["Boss"]}));
|
||||
assert_eq!(s.model.state().body("Plate").map(|b| b.body.shell_count()), Some(1));
|
||||
let want = 40.0 * 40.0 * 10.0 + 10.0 * 10.0 * 10.0 - 10.0 * 5.0 * 5.0;
|
||||
assert!((volume(&s, "Plate") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Plate"));
|
||||
|
||||
let mut s = Session::default();
|
||||
turned(&mut s, json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, 0], "body_name": "Peg"}));
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "operation": "new", "body_name": "Base"}));
|
||||
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Peg"], "operation": "cut"}));
|
||||
let want = 40.0 * 40.0 * 10.0 - 10.0 * 10.0 * 5.0;
|
||||
assert!((volume(&s, "Base") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Base"));
|
||||
}
|
||||
|
||||
/// #3383: a feature joining into a body of another, placed component draws its tool where the
|
||||
/// tool is shown, as Combine does. Part is moved 100 mm along x, so its 10 mm cubes sit at x 100..110
|
||||
/// and 120..130; a box and an extrude drawn from the root over each add 4 x 4 x 5 to it in one
|
||||
/// piece, not a separate lump 100 mm further on. A through-all cut is as long as the target is
|
||||
/// far where it is shown.
|
||||
#[test]
|
||||
fn joins_into_a_moved_occurrence_meet_the_tool_where_it_is_shown() {
|
||||
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
|
||||
let mut s = Session::default();
|
||||
created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 0]}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 15, "corner": [103, 3, 0], "operation": "join", "targets": ["A"]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [123, 3], "p1": [127, 7]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 15, "operation": "join", "targets": ["B"]}));
|
||||
let want = 10.0 * 10.0 * 10.0 + 4.0 * 4.0 * 5.0;
|
||||
for b in ["A", "B"] {
|
||||
assert_eq!(s.model.state().body(b).map(|b| b.body.shell_count()), Some(1), "{b}");
|
||||
assert!((volume(&s, b) - want).abs() < 1e-6 * want, "{b}: {} vs {want}", volume(&s, b));
|
||||
}
|
||||
|
||||
// Through all reaches the target where it is shown: Part moved 100 mm along x and up, a cut
|
||||
// drawn on the root's XY plane goes 100 mm up through its cube, not 10 mm.
|
||||
let mut s = Session::default();
|
||||
created(&mut s, "Part");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "C"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 100]}));
|
||||
run(&mut s, "component.capture_position", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [103, 3], "p1": [107, 7]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["C"]}));
|
||||
let want = 10.0 * 10.0 * 10.0 - 4.0 * 4.0 * 10.0;
|
||||
assert!((volume(&s, "C") - want).abs() < 1e-6 * want, "C: {} vs {want}", volume(&s, "C"));
|
||||
}
|
||||
|
||||
@@ -549,3 +549,29 @@ fn copies_of_a_primitive_cut_only_its_targets() {
|
||||
assert!(rel(removed, holes * PI * 9.0 * 10.0) < 1e-3, "{id}: {removed} mm³ cut from Plaque");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3378: a pattern or mirror of a join whose targets the join merged into one body copies onto
|
||||
/// that body, as before #2755, instead of looking for the target the join used up.
|
||||
#[test]
|
||||
fn copies_of_a_join_that_merged_its_targets_join_the_merged_body() {
|
||||
for (copy, n) in [
|
||||
(json!(["solid.pattern.circular", {"features": ["spoke"], "axis": "Y", "count": 5}]), 5.0),
|
||||
(json!(["solid.mirror", {"features": ["spoke"], "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}}]), 2.0),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 66, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "rim"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 63, "height": 3, "base": [0, 0, 0], "axis": "Y", "operation": "cut", "targets": ["rim"]}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 12, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "hub"}));
|
||||
let v0 = volume(&mut s);
|
||||
let spoke = json!({"length": 52, "width": 3, "height": 2.5, "corner": [12, 0, -1.25], "operation": "join", "targets": ["rim", "hub"], "name": "spoke"});
|
||||
run(&mut s, "solid.box", spoke);
|
||||
let v1 = volume(&mut s);
|
||||
let (id, p) = (copy[0].as_str().unwrap_or_default(), copy[1].clone());
|
||||
run(&mut s, id, p);
|
||||
let names = run(&mut s, "inspect.measure", json!({}))["bodies"].as_array().map(|b| b.iter().map(|x| x["name"].clone()).collect::<Vec<_>>());
|
||||
assert_eq!(names, Some(vec![json!("rim")]), "{id}: one body, the merged rim and hub");
|
||||
// Every copy adds what the seed spoke added (curved bodies measure to about 0.02 %; a
|
||||
// missing or doubled spoke is about 5 %).
|
||||
assert!(rel(volume(&mut s), v0 + n * (v1 - v0)) < 1e-3, "{id}: {} vs {}", volume(&mut s), v0 + n * (v1 - v0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,7 +359,10 @@ fn designs_from_before_bend_edges_open_unchanged() {
|
||||
assert!(!older.to_string().contains("\"bends\""), "{older}");
|
||||
older["format"] = json!("solidcraft/16");
|
||||
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
|
||||
assert_eq!(doc, *s.doc);
|
||||
// An older design keeps the numbering of the pieces it was saved with (#3902).
|
||||
let mut same = solidcraft_doc::Document::clone(&s.doc);
|
||||
same.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
|
||||
assert_eq!(doc, same);
|
||||
let mut reopened = Session::new(doc);
|
||||
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
|
||||
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
|
||||
|
||||
@@ -4878,3 +4878,108 @@ fn a_sheet_made_of_sheet_features_and_flat_cuts_still_exports_its_flat_pattern()
|
||||
}
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
|
||||
}
|
||||
|
||||
/// An L-bracket with a hole off its centre and two flanges, so that its mirror image is the
|
||||
/// opposite hand: a 100 x 60 base on XY with a 10 mm hole at (25, 40), a 20 mm flange on the
|
||||
/// front edge and a 15 mm one on the right edge.
|
||||
fn asymmetric_bracket(s: &mut Session) {
|
||||
run(s, "sheet.manage_rules", json!({"name": "S", "thickness": "2 mm", "bend_radius": "2 mm", "k_factor": 0.4, "active": true}));
|
||||
run(s, "sketch.create", json!({"plane": "XY", "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!({"type": "base"}));
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "name": "F1"}));
|
||||
run(s, "sheet.flange", json!({"edges": [[100, 30, 2]], "height": 15, "name": "F2"}));
|
||||
circle_sketch(s, "Hole", json!("XY"), [25.0, 40.0], 10.0);
|
||||
run(s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
}
|
||||
|
||||
/// The points of a flat pattern's loops, `x` scaled by `sx`, sorted, rounded to 1e-6 mm.
|
||||
fn flat_points(v: &Value, sx: f64) -> Vec<(i64, i64)> {
|
||||
let mut out: Vec<(i64, i64)> = v
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
|
||||
.map(|p| ((p[0].as_f64().unwrap_or(f64::NAN) * sx * 1e6).round() as i64, (p[1].as_f64().unwrap_or(f64::NAN) * 1e6).round() as i64))
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
fn body_volume(s: &mut Session, name: &str) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
m["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == name).and_then(|b| b["volume_mm3"].as_f64()).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mirrored_sheet_copy_is_the_opposite_hand_with_the_mirror_image_flat_pattern() {
|
||||
let mut s = Session::default();
|
||||
asymmetric_bracket(&mut s);
|
||||
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
|
||||
let mirrored = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1 (2)"}));
|
||||
// The same blank, the other way round: K-factor flat length, bends and thickness are kept.
|
||||
assert_eq!(mirrored["flat_size_mm"], original["flat_size_mm"], "{mirrored}");
|
||||
assert_eq!(mirrored["thickness"], original["thickness"]);
|
||||
assert_eq!(flat_points(&mirrored["outline"], 1.0), flat_points(&original["outline"], -1.0));
|
||||
assert_eq!(original["cutouts"].as_array().map(Vec::len), Some(1), "{original}");
|
||||
assert_eq!(flat_points(&mirrored["cutouts"], 1.0), flat_points(&original["cutouts"], -1.0));
|
||||
let bends = |f: &Value| -> Vec<(String, i64, i64)> {
|
||||
let mut b: Vec<_> = f["bends"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|b| {
|
||||
(
|
||||
b["direction"].to_string(),
|
||||
(b["allowance"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
|
||||
(b["length"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
b.sort();
|
||||
b
|
||||
};
|
||||
assert_eq!(bends(&mirrored), bends(&original));
|
||||
assert_eq!(bends(&mirrored).len(), 2);
|
||||
// The hole is where the mirror put it, on the copy and in its flat pattern.
|
||||
let in_copy = |p: [f64; 3]| {
|
||||
s.model.state().bodies.iter().find(|b| b.name == "Body1 (2)").is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
|
||||
};
|
||||
assert!(!in_copy([-25.0, 40.0, 1.0]) && in_copy([-35.0, 40.0, 1.0]));
|
||||
let hole = flat_points(&mirrored["cutouts"], 1.0);
|
||||
let cx = hole.iter().map(|p| p.0 as f64).sum::<f64>() / hole.len().max(1) as f64 * 1e-6;
|
||||
assert!((cx + 25.0).abs() < 1e-3, "hole centre x {cx}");
|
||||
// The sheet model is the mirrored body: a flange added to the copy grows it from its own
|
||||
// back edge, and its DXF is that of a sheet.
|
||||
let before = body_volume(&mut s, "Body1 (2)");
|
||||
assert!(rel(before, body_volume(&mut s, "Body1")) < 1e-9);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[-50, 60, 2]], "height": 10, "body": "Body1 (2)"}));
|
||||
let after = body_volume(&mut s, "Body1 (2)");
|
||||
assert!(after > before + 100.0, "{before} -> {after}");
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({"body": "Body1 (2)"}));
|
||||
assert_eq!(dxf["bends"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_or_copying_a_sheet_body_leaves_the_original_sheet_as_it_was() {
|
||||
let mut s = Session::default();
|
||||
asymmetric_bracket(&mut s);
|
||||
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
|
||||
let volume = body_volume(&mut s, "Body1");
|
||||
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 200, 0], "copy": true}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Body1"], "dir1": [0, 0, 1], "count1": 2, "spacing1": 100}));
|
||||
assert_eq!(run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"})), original);
|
||||
assert!(rel(body_volume(&mut s, "Body1"), volume) < 1e-9);
|
||||
// A moved or patterned copy is the same hand: the same flat pattern, as a sheet of its own.
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names.len(), 4, "{names:?}");
|
||||
for name in names.iter().filter(|n| *n != "Body1" && *n != "Body1 (2)") {
|
||||
let mut copy = run(&mut s, "sheet.flat_pattern", json!({"body": name}));
|
||||
copy["body"] = original["body"].clone();
|
||||
assert_eq!(flat_points(©["outline"], 1.0), flat_points(&original["outline"], 1.0), "{name}");
|
||||
assert_eq!(copy["bends"], original["bends"], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1864,8 +1864,11 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cb = sk.curve_index(rb).ok_or_else(|| bad(cmd, format!("unknown entity `{rb}`")))?;
|
||||
match (sk.curves.get(ca).map(|c| c.kind.clone()), sk.curves.get(cb).map(|c| c.kind.clone())) {
|
||||
(Some(CurveKind::Line { a: a0, b: a1 }), Some(CurveKind::Line { a: b0, b: b1 })) => {
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: b0, l })?);
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: b1, l })?);
|
||||
// Opposite sides of a rectangle run in opposite directions: pair each end
|
||||
// with the end of the other line nearest its mirror image (#2961, #2940).
|
||||
let (p0, p1) = if mirror_pairing_is_crossed(sk, l, [a0, a1], [b0, b1]) { (b1, b0) } else { (b0, b1) };
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: p0, l })?);
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: p1, l })?);
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
let pa = round_center(sk, ca).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?;
|
||||
@@ -1886,6 +1889,25 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"constraints": ids, "sketch": info}))
|
||||
}
|
||||
|
||||
/// True when the ends of line `a` mirror (about the line `l`) closer onto the ends of `b` crossed
|
||||
/// (`a0` with `b1`, `a1` with `b0`) than in order. A degenerate symmetry line keeps the order.
|
||||
fn mirror_pairing_is_crossed(sk: &Sketch, l: usize, a: [usize; 2], b: [usize; 2]) -> bool {
|
||||
let Some((o, e)) = line_pts(sk, l) else { return false };
|
||||
let d = e - o;
|
||||
let len2 = d.x * d.x + d.y * d.y;
|
||||
if len2 < 1e-18 {
|
||||
return false;
|
||||
}
|
||||
let pt = |i: usize| sk.point(i).unwrap_or_default();
|
||||
let near = |i: usize, j: usize| {
|
||||
let v = pt(i) - o;
|
||||
let t = (v.x * d.x + v.y * d.y) / len2;
|
||||
let foot = o + d * t;
|
||||
(foot * 2.0 - pt(i)).dist(pt(j))
|
||||
};
|
||||
near(a[0], b[1]) + near(a[1], b[0]) < near(a[0], b[0]) + near(a[1], b[1])
|
||||
}
|
||||
|
||||
/// Symmetry about point `c`: two points, two circles/arcs (their centres, and equal radii) or two
|
||||
/// lines (each end with the end of the other line nearest its mirror image). The constraints
|
||||
/// (the construction lines show in the response's `created`).
|
||||
|
||||
@@ -374,6 +374,66 @@ fn symmetry_moves_a_free_line_between_two_fixed_lines() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 20 x 10 rectangle (sides l1..l4; left = l4 and right = l2 run in opposite directions) and a
|
||||
/// free vertical line at x = -20 as the symmetry line (#2940, #2961).
|
||||
fn rectangle_and_axis(dimension_left: bool) -> (Session, Vec<String>) {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, 0], "p1": [8, 10]}));
|
||||
let sides = ids(&r["curves"]);
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20, -5], [-20, 15]], "construction": true, "ids": ["axis"]}));
|
||||
if dimension_left {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [sides[3].clone()], "value": 10}));
|
||||
}
|
||||
(s, sides)
|
||||
}
|
||||
|
||||
/// Both sides keep their 10 mm height and their ends mirror in x about `axis_x`.
|
||||
fn assert_sides_mirror_with_height(s: &Session, left: &str, right: &str, axis_x: f64) {
|
||||
let sk = sketch(s);
|
||||
let at = |r: String| sk.point(sk.resolve_point(&r).unwrap()).unwrap();
|
||||
for side in [left, right] {
|
||||
let (a, b) = (at(format!("{side}.start")), at(format!("{side}.end")));
|
||||
assert!((a.y - b.y).abs() > 9.999 && (a.y - b.y).abs() < 10.001, "{side} height {:?} {:?}", a, b);
|
||||
assert!((a.x - b.x).abs() < 1e-7, "{side} not vertical: {:?} {:?}", a, b);
|
||||
}
|
||||
let (l, r) = (at(format!("{left}.start")), at(format!("{right}.start")));
|
||||
assert!((l.x + r.x - 2.0 * axis_x).abs() < 1e-6, "sides not mirrored: {l:?} {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetry_of_rectangle_sides_keeps_their_height() {
|
||||
// #2940: the opposite sides of a rectangle run in opposite directions, so pairing their ends
|
||||
// by index forced both heights to 0.
|
||||
let (mut s, sides) = rectangle_and_axis(false);
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "axis"}));
|
||||
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetry_of_a_dimensioned_rectangle_side_is_accepted() {
|
||||
// #2961: with a length on a side, the same symmetry was refused as "neither entity can move".
|
||||
let (mut s, sides) = rectangle_and_axis(true);
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "axis"}));
|
||||
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetry_of_rectangle_sides_with_swapped_arguments() {
|
||||
let (mut s, sides) = rectangle_and_axis(true);
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[1], "b": sides[3], "line": "axis"}));
|
||||
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetry_of_rectangle_sides_about_the_y_axis() {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, 0], "p1": [8, 10]}));
|
||||
let sides = ids(&r["curves"]);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [sides[3].clone()], "value": 10}));
|
||||
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "y"}));
|
||||
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_alignment_persists_undoes_and_survives_endpoint_edits() {
|
||||
use solidcraft_sketch::ConstraintKind;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextExprs, TextStyle};
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
|
||||
|
||||
/// The style parameters of `sketch.text` and `sketch.edit_text`.
|
||||
macro_rules! text_style_doc {
|
||||
@@ -822,6 +822,39 @@ fn guess_radius(sk: &Sketch, curves: &[usize], at: Vec2) -> f64 {
|
||||
if r.is_finite() && r > 1e-6 { r } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Where the centre of a circle of radius `d` touching `sh` can lie: the line moved `d` to either
|
||||
/// side, or the circle grown and shrunk by `d` (arcs as their full circle).
|
||||
fn centre_paths(sh: &Shape, d: f64) -> Vec<Shape> {
|
||||
match *sh {
|
||||
Shape::Line { a, b } => {
|
||||
(b - a).normalized().map(|u| [d, -d].map(|s| Shape::Line { a: a + u.perp() * s, b: b + u.perp() * s }).to_vec()).unwrap_or_default()
|
||||
}
|
||||
Shape::Round { c, r, .. } => [r + d, (r - d).abs()]
|
||||
.into_iter()
|
||||
.filter(|x| *x > 1e-12)
|
||||
.map(|r| Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre nearest `near` of a circle of radius `r` touching curves `a` and `b` (#3141): the
|
||||
/// solver starts from the answer instead of from the click, which lies on a curve, where the
|
||||
/// tangency has no side yet and the solve fails or moves the curves.
|
||||
fn tangent_centre(sk: &Sketch, a: usize, b: usize, r: f64, near: Vec2) -> Option<Vec2> {
|
||||
let (sa, sb) = (sk.shape(a)?, sk.shape(b)?);
|
||||
let mut best: Option<Vec2> = None;
|
||||
for x in centre_paths(&sa, r) {
|
||||
for y in centre_paths(&sb, r) {
|
||||
for q in solidcraft_sketch::intersections_within(&x, &y, 1e-9 * (1.0 + r)) {
|
||||
if q.x.is_finite() && q.y.is_finite() && best.is_none_or(|b| q.dist(near) < b.dist(near)) {
|
||||
best = Some(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn tangent_curves(sk: &Sketch, p: &Value, cmd: &str, n: usize) -> Result<Vec<usize>> {
|
||||
let ids = string_list(p, "curves");
|
||||
if ids.len() != n {
|
||||
@@ -842,10 +875,14 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
|
||||
if !(r0 > 1e-9 && r0 < 1e8) {
|
||||
return Err(bad(cmd, "radius must be positive"));
|
||||
}
|
||||
// Start where the circle already nearly touches: move the centre so the guessed circle
|
||||
// sits between the curves.
|
||||
// Start where the circle already touches when that can be worked out (two curves and a
|
||||
// radius), so that only the new circle moves.
|
||||
let start = match cs.as_slice() {
|
||||
[a, b] if radius.is_some() => tangent_centre(sk, *a, *b, r0, near).unwrap_or(near),
|
||||
_ => near,
|
||||
};
|
||||
let first_new = sk.points.len();
|
||||
let c = sk.add_circle(near, r0, None, None)?;
|
||||
let c = sk.add_circle(start, r0, None, None)?;
|
||||
let mut cons = Vec::new();
|
||||
for k in &cs {
|
||||
cons.push(add_c(sk, ConstraintKind::Tangent { a: *k, b: c })?);
|
||||
|
||||
@@ -723,3 +723,17 @@ fn a_custom_plane_origin_that_is_not_numbers_is_refused_not_zeroed() {
|
||||
assert_eq!(origin_of(&mut s, json!({"origin": [20, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([20.0, 0.0, 0.0]));
|
||||
assert_eq!(origin_of(&mut s, json!({"x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([0.0, 0.0, 0.0]));
|
||||
}
|
||||
/// A sketch on a body's face projects that face's edges from the body's display mesh. The body
|
||||
/// it projects from is the model's own, not a copy that meshes it again: on a body whose curved
|
||||
/// edges are slow to mesh, the copy's mesh was thrown away and the next feature meshed the body
|
||||
/// a second time, which made a sketch on a face as slow as a cut.
|
||||
#[test]
|
||||
fn a_sketch_on_a_face_meshes_its_body_once() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
|
||||
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_none());
|
||||
let pick = top_pick(&s, "Base");
|
||||
run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_some(), "the sketch's projection meshed a copy of the body");
|
||||
}
|
||||
|
||||
@@ -438,6 +438,51 @@ fn new_creation_tools() {
|
||||
assert!(a.iter().any(|x| close(*x, pi * 5.0 * 2.0 + pi)), "{a:?}");
|
||||
}
|
||||
|
||||
/// #3141: a 2-tangent circle asked for near a point on one of its curves (where a click on that
|
||||
/// curve puts it) is solved from the touching circle nearest that point: it lands in the corner,
|
||||
/// the rectangle stays as drawn, and fixed sides no longer make it fail.
|
||||
#[test]
|
||||
fn two_tangent_circle_from_a_click_on_a_curve_leaves_the_curves_as_drawn() {
|
||||
let side = |s: &Session, sides: &[String], on: fn(Vec2) -> bool| -> String {
|
||||
let sk = sketch(s);
|
||||
sides
|
||||
.iter()
|
||||
.find(|c| {
|
||||
let i = sk.curve_index(c).unwrap();
|
||||
matches!(sk.shape(i), Some(solidcraft_sketch::Shape::Line { a, b }) if on(a) && on(b))
|
||||
})
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
let points = |s: &Session| sketch(s).points.iter().map(|p| p.pos).collect::<Vec<_>>();
|
||||
for fixed in [false, true] {
|
||||
let mut s = new_sketch();
|
||||
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-800, 0], "p1": [-300, 500]}));
|
||||
let sides = ids(&r["curves"]);
|
||||
if fixed {
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": sides}));
|
||||
}
|
||||
let before = points(&s);
|
||||
let bottom = side(&s, &sides, |p| p.y.abs() < 1e-9);
|
||||
let left = side(&s, &sides, |p| (p.x + 800.0).abs() < 1e-9);
|
||||
// Midway between a click on the bottom side and one on the left side.
|
||||
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [bottom, left], "radius": 50, "near": [-675, 125]}));
|
||||
let c = ids(&r["curves"])[0].clone();
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index(&c).unwrap();
|
||||
assert!(sk.center(ci).unwrap().dist(Vec2::new(-750.0, 50.0)) < 1e-6, "fixed {fixed}: {:?}", sk.center(ci));
|
||||
assert!(close(sk.radius(ci).unwrap(), 50.0));
|
||||
let after = points(&s);
|
||||
assert!(before.iter().zip(&after).all(|(a, b)| a.dist(*b) < 1e-9), "fixed {fixed}: the rectangle did not move: {after:?}");
|
||||
// Asked for right on the left side: still a touching circle next to it.
|
||||
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [bottom, left], "radius": 20, "near": [-800, 30]}));
|
||||
let c = ids(&r["curves"])[0].clone();
|
||||
let sk = sketch(&s);
|
||||
let o = sk.center(sk.curve_index(&c).unwrap()).unwrap();
|
||||
assert!(close(o.y, 20.0) && close((o.x + 800.0).abs(), 20.0), "fixed {fixed}: {o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_splines_conic() {
|
||||
let pi = std::f64::consts::PI;
|
||||
|
||||
@@ -17,6 +17,62 @@ fn body(s: &mut Session, b: &str) -> Value {
|
||||
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone()
|
||||
}
|
||||
|
||||
fn stepped_block() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "step_height", "expression": "10 mm"}));
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 10, "body_name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -5], "p1": [5, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "step_height", "operation": "join"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A column beside a step ends at the step's top, including when the To point lies on an
|
||||
/// edge or corner shared with a vertical wall (#3729).
|
||||
#[test]
|
||||
fn extrude_to_a_planar_face_boundary_ends_at_its_plane() {
|
||||
for to in [[5, 0, 20], [5, 5, 20], [0, 0, 20], [1000, 1000, 20]] {
|
||||
let mut s = stepped_block();
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"to": to, "operation": "join"}));
|
||||
let b = body(&mut s, "Block");
|
||||
let top = b["bbox"]["max"][2].as_f64().unwrap();
|
||||
let volume = b["volume_mm3"].as_f64().unwrap();
|
||||
assert!((top - 20.0).abs() < 1e-6, "To {to:?}: z max {top}");
|
||||
assert!((volume - (5000.0 + 10.0 * PI * 1.5 * 1.5)).abs() < 0.1, "To {to:?}: volume {volume}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The face chosen at an edge must also be the stored reference: raising the step must raise
|
||||
/// the end of the extrusion, including after a save/reload.
|
||||
#[test]
|
||||
fn extrude_to_a_planar_face_boundary_follows_it_after_reload() {
|
||||
for to in [[5, 0, 20], [5, 5, 20]] {
|
||||
let mut s = stepped_block();
|
||||
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// A negative reach with an offset past the target, away from the start.
|
||||
run(&mut s, "solid.extrude", json!({"to": to, "to_offset": 2, "operation": "new", "body_name": "Post"}));
|
||||
let b = body(&mut s, "Post");
|
||||
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 18.0).abs() < 1e-6, "{b}");
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let mut cold = Session::new(doc);
|
||||
for current in [&mut s, &mut cold] {
|
||||
run(current, "parameters.change", json!({"name": "step_height", "expression": "15 mm"}));
|
||||
let b = body(current, "Post");
|
||||
let volume = b["volume_mm3"].as_f64().unwrap();
|
||||
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 23.0).abs() < 1e-6, "{b}");
|
||||
assert!((b["bbox"]["max"][2].as_f64().unwrap() - 30.0).abs() < 1e-6, "{b}");
|
||||
assert!((volume - 7.0 * PI * 1.5 * 1.5).abs() < 0.1, "{b}");
|
||||
assert!(current.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", current.model.results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A square post extruded up to a horizontal cylinder (axis along x, r 10, centre z 30): its
|
||||
/// top takes the cylinder's shape, so the post is shortest in the middle.
|
||||
#[test]
|
||||
|
||||
@@ -374,3 +374,44 @@ fn drilled_sketch_points_break_through_a_sloping_back_face() {
|
||||
assert!(reopened.model.result(id).unwrap().error.is_none());
|
||||
near(volume(&mut reopened), left);
|
||||
}
|
||||
|
||||
/// Back (z 0…6) under a smaller Front (z 14…20), and a sketch on Front's top face with points
|
||||
/// over Front (`in`) and beyond its edges, over Back (`out1`, `out2`).
|
||||
fn front_and_back(s: &mut Session) {
|
||||
run(s, "solid.box", json!({"length": 40, "width": 40, "height": 6, "body_name": "Back"}));
|
||||
run(s, "solid.box", json!({"corner": [10, 10, 14], "length": 20, "width": 20, "height": 6, "body_name": "Front"}));
|
||||
run(s, "sketch.create", json!({"plane": {"face": [20, 20, 20]}, "name": "Pts", "project_edges": false}));
|
||||
for (id, p) in [("in", [20, 20]), ("out1", [5, 5]), ("out2", [35, 35])] {
|
||||
run(s, "sketch.point", json!({"point": p, "id": id}));
|
||||
}
|
||||
run(s, "sketch.finish", json!({}));
|
||||
}
|
||||
|
||||
/// #2882: holes at the points of a sketch on a body's face drill that body, never one behind it.
|
||||
#[test]
|
||||
fn holes_from_a_sketch_on_a_face_drill_that_body_only() {
|
||||
let mut s = Session::default();
|
||||
front_and_back(&mut s);
|
||||
let base = volume(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in", "out1", "out2"], "diameter": 5, "depth": 4, "name": "H"}));
|
||||
assert_eq!(s.doc.find_feature("H").unwrap().participants, vec!["Front".to_string()]);
|
||||
near(base - volume(&mut s), pi() * 6.25 * 4.0);
|
||||
assert!(!solid_at(&s, 20.0, 20.0, 18.0), "drilled into Front");
|
||||
for (x, y) in [(5.0, 5.0), (35.0, 35.0), (20.0, 20.0)] {
|
||||
assert!(solid_at(&s, x, y, 5.5) && solid_at(&s, x, y, 3.0), "Back is not drilled at ({x}, {y})");
|
||||
}
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
near(base - volume(&mut reopened), pi() * 6.25 * 4.0);
|
||||
}
|
||||
|
||||
/// Through all from Front's face stops at Front; `targets` picks another body to drill instead.
|
||||
#[test]
|
||||
fn face_sketch_holes_through_all_stay_in_their_body_unless_targets_say_otherwise() {
|
||||
let mut s = Session::default();
|
||||
front_and_back(&mut s);
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "name": "H"}));
|
||||
assert!(!solid_at(&s, 20.0, 20.0, 15.0) && solid_at(&s, 20.0, 20.0, 3.0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "targets": ["Back"], "name": "H"}));
|
||||
assert!(solid_at(&s, 20.0, 20.0, 15.0) && !solid_at(&s, 20.0, 20.0, 3.0));
|
||||
}
|
||||
|
||||
@@ -170,9 +170,19 @@ pub struct Snapshot {
|
||||
pub struct Ahead(Session);
|
||||
|
||||
impl Ahead {
|
||||
/// Run `commands` (their results stay in the shared evaluation cache).
|
||||
/// What the evaluation computes, kept for the run that follows here whatever the evaluation
|
||||
/// cache drops meanwhile: hand it to [`solidcraft_doc::Model::keep`] for that run.
|
||||
pub fn kept(&self) -> Arc<solidcraft_doc::KeptResults> {
|
||||
self.0.model.kept().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Run `commands` (their results stay in the shared evaluation cache, and in [`Ahead::kept`]),
|
||||
/// then make the display meshes and plans of the bodies they end with: the bodies the run
|
||||
/// here finds share them, so the window draws, fits and picks without tessellating anything.
|
||||
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> {
|
||||
self.0.preview_in_place(commands).map(|_| ())
|
||||
let (s, _) = self.0.preview_in_place(commands)?;
|
||||
s.world_state().warm_display();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
|
||||
@@ -431,16 +441,24 @@ impl Session {
|
||||
|
||||
/// Become `fresh` (another design opened, recovered or started), keeping the settings that
|
||||
/// are the user's rather than the design's: `auto_project` is a preference (#680).
|
||||
/// Checkpoints survive too: a batch that failed after `file.new` or `doc.open` goes back
|
||||
/// from the checkpoint it took before its first step, and that checkpoint belongs to the
|
||||
/// design before the batch, which is what the batch promised (#3242). The design is what a
|
||||
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
|
||||
pub fn replace_design(&mut self, fresh: Session) {
|
||||
let auto_project = self.auto_project;
|
||||
let param_check = self.param_check;
|
||||
let render_viewport = self.render_viewport.clone();
|
||||
let render_jobs = self.render_jobs.scratch();
|
||||
let checkpoints = std::mem::take(&mut self.checkpoints);
|
||||
let next_checkpoint = self.next_checkpoint;
|
||||
*self = fresh;
|
||||
self.auto_project = auto_project;
|
||||
self.param_check = param_check;
|
||||
self.render_viewport = render_viewport;
|
||||
self.render_jobs = render_jobs;
|
||||
self.checkpoints = checkpoints;
|
||||
self.next_checkpoint = next_checkpoint;
|
||||
}
|
||||
|
||||
/// The design has changes not in its file: in the document, or in what is hidden.
|
||||
@@ -1022,6 +1040,7 @@ impl Session {
|
||||
let mut copy = self.scratch();
|
||||
copy.undo.clone_from(&self.undo);
|
||||
copy.redo.clone_from(&self.redo);
|
||||
copy.model.keep(Some(Arc::default()));
|
||||
Ahead(copy)
|
||||
}
|
||||
|
||||
|
||||
@@ -278,3 +278,53 @@ fn step_round_trip_of_a_plate_with_holes() {
|
||||
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// A part ring cut by `solid.combine` (two prisms through its ends, a notch through its chamfer)
|
||||
// measures what it encloses, wherever it is moved. The reference mesher left a crack where the
|
||||
// notch meets the chamfer's cone; the volume beside it was left out (0.2 %), and integrated
|
||||
// about the world origin the loss grew with the distance (7 % moved 1000 mm away).
|
||||
|
||||
fn volume(s: &mut Session, body: &str) -> f64 {
|
||||
run(s, "inspect.measure", json!({"bodies": [body]}))["total"]["volume_mm3"].as_f64().unwrap()
|
||||
}
|
||||
|
||||
fn prism(s: &mut Session, offset: f64, points: Value, distance: f64, name: &str) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": offset}));
|
||||
run(s, "sketch.line", json!({"points": points, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"distance": distance, "operation": "new", "body_name": name}));
|
||||
}
|
||||
|
||||
/// A part ring (210° of a chamfered section) less two oblique prisms and a notch.
|
||||
fn cut_ring(s: &mut Session) {
|
||||
let (c, si) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
|
||||
run(s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [si, c, 0], "y_dir": [0, 0, -1]}}));
|
||||
let section = json!([[16.6, 0], [17.5, 0], [18.5, -1.0], [18.5, -7.0], [17.5, -8.0], [16.6, -8.0], [16.4, -7.8], [16.4, -0.2]]);
|
||||
run(s, "sketch.line", json!({"points": section, "closed": true}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.revolve", json!({"axis": "Z", "angle": "210 deg", "body_name": "Ring"}));
|
||||
let quad = |sy: f64| json!([[4.644832, 15.101839 * sy], [5.17399, 16.822301 * sy], [4.555215, 17.000295 * sy], [2.187187, 15.574386 * sy]]);
|
||||
prism(s, -1.0, quad(1.0), 10.0, "T0");
|
||||
prism(s, -1.0, quad(-1.0), 10.0, "T1");
|
||||
prism(s, 7.1, json!([[-17.6, 0.45], [-19.5, 0.45], [-19.5, -0.45], [-17.6, -0.45]]), 1.9, "N0");
|
||||
run(s, "solid.combine", json!({"target": "Ring", "tools": ["T0", "T1", "N0"], "operation": "cut"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_cut_volume_does_not_depend_on_placement() {
|
||||
let mut s = Session::default();
|
||||
cut_ring(&mut s);
|
||||
let here = volume(&mut s, "Ring");
|
||||
// The 210° ring by Pappus less what the prisms and the notch take (a fine grid quadrature
|
||||
// of each, independent of the kernel's meshes): 1005.489 − 2 × 5.639 − 0.287.
|
||||
let want = 993.924;
|
||||
assert!((here - want).abs() <= 2e-4 * want, "at the origin: {here} vs {want}");
|
||||
let mut moved = Vec::new();
|
||||
for by in [[100.0, 0.0, 0.0], [900.0, 0.0, 0.0], [-1000.0, 100.0, 0.0], [0.0, 0.0, 10000.0], [0.0, -10000.0, 0.0]] {
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Ring"], "translate": by}));
|
||||
moved.push((by, volume(&mut s, "Ring")));
|
||||
}
|
||||
for (by, v) in moved {
|
||||
assert!((v - here).abs() <= 1e-6 * here, "after a move by {by:?}: {v} vs {here} at the origin");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,3 +401,61 @@ fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
|
||||
assert_eq!(blends, 4, "{filleted:?}");
|
||||
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
|
||||
}
|
||||
|
||||
/// #3902: a torus with a closed, twisted square sweep cut inside it splits the sweep's walls into
|
||||
/// pieces the kernel returns in a different order on each evaluation. The pieces are numbered by
|
||||
/// where they lie, so every fresh evaluation gives each piece the same name.
|
||||
#[test]
|
||||
fn split_pieces_keep_their_numbers_across_evaluations() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json")).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
let body = s.model.state().bodies.first().map(|b| b.name.clone()).expect("a body");
|
||||
let named = |s: &mut Session| -> Vec<(String, [i64; 3])> {
|
||||
let mut faces: Vec<(String, [i64; 3])> = listed(s, &body).into_iter().map(|(n, _, c)| (n, c.map(|x| x.round() as i64))).collect();
|
||||
faces.sort();
|
||||
faces
|
||||
};
|
||||
let first = named(&mut s);
|
||||
assert!(first.iter().any(|(n, _)| n.contains('#')), "no split pieces: {first:?}");
|
||||
for run in 0..10 {
|
||||
let mut fresh = Session::new(solidcraft_doc::Document::clone(&s.doc));
|
||||
fresh.refresh();
|
||||
assert_eq!(named(&mut fresh), first, "evaluation {run}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3902: a design saved before the pieces of a face were numbered by place opens with the
|
||||
/// numbers it was saved with, so a reference to `…#1` stays on the piece it was made on. The
|
||||
/// block cut by the second block here has its side split in two, numbered the other way round
|
||||
/// by place.
|
||||
#[test]
|
||||
fn a_design_saved_before_pieces_were_numbered_by_place_keeps_their_numbers() {
|
||||
let script: Value = serde_json::from_str(include_str!("../../../tests/action_matrix/designs/multi-body-join-cut.json")).unwrap();
|
||||
let mut s = Session::default();
|
||||
s.run_script(&script).unwrap();
|
||||
let by_place = listed(&mut s, "Block");
|
||||
// As a build before the change saved it: its features carry no mark, and the file does not
|
||||
// list the change among those it has.
|
||||
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
for f in file["features"].as_array_mut().unwrap() {
|
||||
f.as_object_mut().unwrap().remove("kernel_piece_order");
|
||||
}
|
||||
if let Some(Value::Array(pending)) = file.get_mut("format_pending") {
|
||||
pending.retain(|c| c != "face-pieces-by-place");
|
||||
}
|
||||
let old = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
|
||||
assert!(old.features.iter().all(|f| f.kernel_piece_order), "the features of an older design keep the old numbering");
|
||||
let mut reopened = Session::new(old);
|
||||
reopened.refresh();
|
||||
let kept = listed(&mut reopened, "Block");
|
||||
let at = |faces: &[(String, bool, [f64; 3])], name: &str| faces.iter().find(|(n, _, _)| n == name).map(|(_, _, c)| *c);
|
||||
// The pieces are numbered differently by place, and the older design keeps its own numbers.
|
||||
let (old1, new1) = (at(&kept, "F2:side#1").unwrap(), at(&by_place, "F2:side#1").unwrap());
|
||||
assert!((0..3).any(|k| (old1[k] - new1[k]).abs() > 1.0), "{kept:?} {by_place:?}");
|
||||
let mut legacy = solidcraft_doc::Document::clone(&s.doc);
|
||||
legacy.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
|
||||
let mut saved_with = Session::new(legacy);
|
||||
saved_with.refresh();
|
||||
assert_eq!(listed(&mut saved_with, "Block"), kept);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Autosave and crash recovery.
|
||||
//!
|
||||
//! A running app keeps one recovery entry in the recovery folder: `<id>.solidcraft` (the design
|
||||
//! Each open design keeps its own recovery entry in the recovery folder: `<id>.solidcraft` (the design
|
||||
//! as of the last autosave), `<id>.json` (its file path, name and time) and `<id>.lock`, which
|
||||
//! the app holds locked while it runs. Autosaves are written atomically, only when the design
|
||||
//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An
|
||||
@@ -163,7 +163,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One app's autosaver.
|
||||
/// One open design's autosaver. Keep it with that design when switching tabs.
|
||||
pub struct Autosaver {
|
||||
pub dir: PathBuf,
|
||||
pub id: String,
|
||||
@@ -232,7 +232,7 @@ impl Snapshot {
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
/// Start autosaving into `dir` (created if needed), holding this app's lock.
|
||||
/// Start autosaving into `dir` (created if needed), holding this design's lock.
|
||||
pub fn new(dir: &Path, interval: Duration) -> Result<Autosaver> {
|
||||
let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display()));
|
||||
fs::create_dir_all(dir).map_err(io)?;
|
||||
@@ -361,6 +361,20 @@ impl Autosaver {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user closed this design (saving it or choosing Don't Save): discard only its entry.
|
||||
pub fn discard(mut self) {
|
||||
// A write still running would recreate the entry, without a lock, after it is removed:
|
||||
// the next launch would offer a design the user closed.
|
||||
if let Some(w) = self.writing.take() {
|
||||
let _ = w.done.recv();
|
||||
}
|
||||
for ext in ["json", solidcraft_io::DESIGN_EXT] {
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
|
||||
}
|
||||
self.lock = None;
|
||||
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
|
||||
}
|
||||
|
||||
/// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next
|
||||
/// launch to offer. The lock is released either way.
|
||||
pub fn close(mut self, s: &Session) {
|
||||
|
||||
@@ -129,6 +129,23 @@ pub(crate) fn whole(message: impl std::fmt::Display) -> String {
|
||||
format!("{WHOLE}{message}")
|
||||
}
|
||||
|
||||
/// Marks a deserializer's message that carries its own path: an error from a flattened
|
||||
/// `FeatureCommon`, which serde can't locate. The path (one of its field names) and the error
|
||||
/// follow as JSON.
|
||||
const LOCATED: char = '\u{2}';
|
||||
|
||||
/// `e`, an error deserializing a flattened `FeatureCommon`, as a deserializer's message that
|
||||
/// keeps its path, for [`parse_issue`].
|
||||
pub(crate) fn located(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
|
||||
let located = serde_json::to_string(&(error_path(e), e.inner().to_string())).unwrap_or_default();
|
||||
format!("{LOCATED}{located}")
|
||||
}
|
||||
|
||||
fn error_path(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
|
||||
let path = e.path().to_string();
|
||||
if path == "." { String::new() } else { path }
|
||||
}
|
||||
|
||||
/// The top-level property names of a schema, following `allOf`/`anyOf`/`oneOf` and `$ref`s
|
||||
/// into the root's `$defs`.
|
||||
pub(crate) fn schema_keys(root: &Value) -> BTreeSet<String> {
|
||||
@@ -207,10 +224,15 @@ fn nearest(key: &str, allowed: &BTreeSet<String>) -> Option<String> {
|
||||
}
|
||||
|
||||
/// A deserialization failure as an issue, in the engine's style.
|
||||
fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
|
||||
let path = e.path().to_string();
|
||||
let path = if path == "." { String::new() } else { path };
|
||||
pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
|
||||
let path = error_path(e);
|
||||
let inner = e.inner().to_string();
|
||||
// An error from a flattened `FeatureCommon`: its field continues the path.
|
||||
let (path, inner) = match inner.strip_prefix(LOCATED).and_then(|r| serde_json::from_str::<(String, String)>(r).ok()) {
|
||||
Some((field, inner)) if path.is_empty() => (field, inner),
|
||||
Some((field, inner)) => (format!("{path}.{field}"), inner),
|
||||
None => (path, inner),
|
||||
};
|
||||
if let Some(complete) = inner.strip_prefix(WHOLE) {
|
||||
// The handler's own message, which names the caller's value: in full up to a generous
|
||||
// cap, since the contract also checks keys the handler ignores (`side` without `thin`).
|
||||
@@ -218,7 +240,7 @@ fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
|
||||
}
|
||||
let message = if let Some(field) = inner.strip_prefix("missing field `").and_then(|r| r.split('`').next()) {
|
||||
let at = if path.is_empty() { field.to_string() } else { format!("{path}.{field}") };
|
||||
return Issue { path: at.clone(), message: format!("`{at}` is required"), suggestion: None };
|
||||
return Issue { path: at.clone(), message: truncate(&format!("`{at}` is required"), MAX_MESSAGE_LEN), suggestion: None };
|
||||
} else if path.is_empty() {
|
||||
inner
|
||||
} else if inner.starts_with("must ") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The converted commands' parameter contracts and result types.
|
||||
//! Contracts and result types for the commands in `cmd/features.rs`.
|
||||
//!
|
||||
//! Field doc comments are the schemas' descriptions. Keys the handlers read only in some
|
||||
//! situations (`sketch` without `face`, the thin-wall keys with `thin: true`) are optional
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::types::{
|
||||
AngleExpr, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FromPoint, IdOrName, LengthExpr, OperationParam, Point3,
|
||||
@@ -161,39 +160,6 @@ pub struct ExtrudeParams {
|
||||
pub thickness2: Option<LengthExpr>,
|
||||
}
|
||||
|
||||
/// Which command to describe.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DescribeParams {
|
||||
/// The command's id, such as `solid.extrude`.
|
||||
#[serde(deserialize_with = "command_id")]
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// A command id, with the `engine.describe` handler's message for anything else.
|
||||
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(serde::de::Error::custom("must be a command id")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `engine.describe` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DescribeResult {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// The command's parameter documentation, as prose.
|
||||
pub params: String,
|
||||
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
|
||||
pub input: Option<Value>,
|
||||
/// The successful result's JSON Schema, or `null` when not described yet.
|
||||
pub output: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a feature command returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FeatureResult {
|
||||
@@ -218,3 +184,7 @@ pub struct FeatureResult {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "features_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,127 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BoxParams, ExtrudeParams, FeatureResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<BoxParams>(),
|
||||
&["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<ExtrudeParams>(),
|
||||
&[
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"convention",
|
||||
"curves",
|
||||
"direction",
|
||||
"distance",
|
||||
"distance2",
|
||||
"extent",
|
||||
"face",
|
||||
"face_body",
|
||||
"faces",
|
||||
"from",
|
||||
"name",
|
||||
"operation",
|
||||
"participants",
|
||||
"profiles",
|
||||
"side",
|
||||
"sketch",
|
||||
"start_offset",
|
||||
"taper",
|
||||
"targets",
|
||||
"thickness",
|
||||
"thickness2",
|
||||
"thin",
|
||||
"through_all",
|
||||
"to",
|
||||
"to_offset",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
|
||||
("solid.box", json!({"length": true, "width": 5, "height": 5})),
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": []})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": []})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": null})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": "true"})),
|
||||
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "from": "x"})),
|
||||
("solid.extrude", json!({"distance": 5, "sketch": true})),
|
||||
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
|
||||
("solid.extrude", json!({"distance": " "})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
|
||||
"removed_bodies": ["C"], "warning": warning, "ms": ms});
|
||||
let new = FeatureResult {
|
||||
feature: 7,
|
||||
name: "Box1".into(),
|
||||
bodies: vec!["A".into(), "B".into()],
|
||||
created_bodies: vec!["B".into()],
|
||||
changed_bodies: vec![],
|
||||
removed_bodies: vec!["C".into()],
|
||||
warning: warning.clone(),
|
||||
ms,
|
||||
param_warnings: vec![],
|
||||
};
|
||||
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert!(!required.contains(&"param_warnings"));
|
||||
let mut s = Session::default();
|
||||
sketch_rectangle(&mut s);
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let replies = [
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
|
||||
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
|
||||
];
|
||||
for r in replies {
|
||||
let r = r.unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
}
|
||||
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
|
||||
// schema leaves other keys open, so a new result key doesn't break clients.)
|
||||
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
|
||||
assert!(conforms(schema, schema, &ok));
|
||||
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
|
||||
let mut bad = ok.clone();
|
||||
bad[k] = v;
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
let mut bad = ok.clone();
|
||||
bad.as_object_mut().map(|o| o.remove("name"));
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Contracts and result types for the commands in `cmd/inspect.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Which command to describe.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct DescribeParams {
|
||||
/// The command's id, such as `solid.extrude`.
|
||||
#[serde(deserialize_with = "command_id")]
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
/// A command id, with the `engine.describe` handler's message for anything else.
|
||||
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(serde::de::Error::custom("must be a command id")),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `engine.describe` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DescribeResult {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// The command's parameter documentation, as prose.
|
||||
pub params: String,
|
||||
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
|
||||
pub input: Option<Value>,
|
||||
/// The successful result's JSON Schema, or `null` when not described yet.
|
||||
pub output: Option<Value>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "inspect_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::DescribeParams;
|
||||
use crate::Session;
|
||||
use crate::schema::input_schema_for;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<DescribeParams>(), &["command"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("engine.describe", json!({"command": 5})),
|
||||
("engine.describe", json!({"command": null})),
|
||||
("engine.describe", json!({"command": "solid.extrud"})),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_returns_both_schemas() {
|
||||
let mut s = Session::default();
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(d["id"], "solid.box");
|
||||
assert!(d["input"]["properties"].get("length").is_some());
|
||||
assert!(d["output"]["properties"].get("created_bodies").is_some());
|
||||
// Its own output schema lists the warnings warn mode can add.
|
||||
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
}
|
||||
@@ -22,15 +22,22 @@
|
||||
//! more lenient (a value of the wrong type read as if the key were absent), the contract is the
|
||||
//! stricter of the two; that difference is what warn and strict surface.
|
||||
//!
|
||||
//! To convert a command: declare its parameters type in `commands.rs` (and its reply type, for
|
||||
//! an output schema), give its `CommandSpec` `checked!(…)`, `.contract(…)` and `.output(…)` as
|
||||
//! `solid.extrude` does, and add it to `schemas_and_the_key_check_agree` and, for messages it
|
||||
//! already gives, `strict_keeps_todays_errors`. Then run the whole test suite with the audit
|
||||
//! recorder (see `record.rs`): a call that succeeds today but breaks the contract is either a
|
||||
//! form the contract is missing or a caller's mistake.
|
||||
//! Contracts live in one module per command file: `features.rs` for `cmd/features.rs`,
|
||||
//! `inspect.rs` for `cmd/inspect.rs`, and so on, each with its own tests (`features_tests.rs`),
|
||||
//! so conversions of different command files touch different files (apart from a new
|
||||
//! module's line below).
|
||||
//!
|
||||
//! To convert a command: declare its parameters type in the module for its command file (and
|
||||
//! its reply type, for an output schema), creating the module if it is the file's first
|
||||
//! contract, from the shared types in [`types`] (the keys every feature takes come from
|
||||
//! [`FeatureCommon`], which `solid.box` and `solid.extrude` predate); give its `CommandSpec`
|
||||
//! `checked!(…)`, `.contract(…)` and `.output(…)` as `solid.extrude` does; and add it to that
|
||||
//! module's `schemas_and_the_key_check_agree` and, for messages it already gives,
|
||||
//! `strict_keeps_todays_errors`. Then run the whole test suite with the audit recorder (see
|
||||
//! `record.rs`): a call that succeeds today but breaks the contract is either a form the
|
||||
//! contract is missing or a caller's mistake.
|
||||
|
||||
mod check;
|
||||
mod commands;
|
||||
#[cfg_attr(target_arch = "wasm32", path = "record_wasm.rs")]
|
||||
mod record;
|
||||
mod types;
|
||||
@@ -38,8 +45,11 @@ mod types;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// The contracts, by command file.
|
||||
pub mod features;
|
||||
pub mod inspect;
|
||||
|
||||
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked};
|
||||
pub use commands::{BoxParams, DescribeParams, DescribeResult, ExtrudeParams, FeatureResult};
|
||||
pub use types::*;
|
||||
|
||||
/// A command's `run` that checks the call against `$contract` before running `$handler`
|
||||
|
||||
+260
-150
@@ -1,7 +1,12 @@
|
||||
//! Tests of the shared types and the checking machinery, and helpers for the per-module tests
|
||||
//! (`features_tests.rs`, …).
|
||||
|
||||
use schemars::Schema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::check::schema_keys;
|
||||
use super::features::{BoxParams, ExtrudeParams};
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
@@ -159,54 +164,6 @@ fn shape_problems_are_issues() {
|
||||
assert!(messages(&c.check(&Value::Object(many))).iter().any(|m| m.starts_with("too many parameters")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
for (schema, fields) in [
|
||||
(
|
||||
input_schema_for::<BoxParams>(),
|
||||
vec!["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
|
||||
),
|
||||
(
|
||||
input_schema_for::<ExtrudeParams>(),
|
||||
vec![
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"convention",
|
||||
"curves",
|
||||
"direction",
|
||||
"distance",
|
||||
"distance2",
|
||||
"extent",
|
||||
"face",
|
||||
"face_body",
|
||||
"faces",
|
||||
"from",
|
||||
"name",
|
||||
"operation",
|
||||
"participants",
|
||||
"profiles",
|
||||
"side",
|
||||
"sketch",
|
||||
"start_offset",
|
||||
"taper",
|
||||
"targets",
|
||||
"thickness",
|
||||
"thickness2",
|
||||
"thin",
|
||||
"through_all",
|
||||
"to",
|
||||
"to_offset",
|
||||
],
|
||||
),
|
||||
] {
|
||||
let v = schema.as_value();
|
||||
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
|
||||
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
|
||||
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_null_is_refused_but_absent_is_fine() {
|
||||
let base = json!({"length": 5, "width": 5, "height": 5});
|
||||
@@ -228,7 +185,7 @@ fn present_null_is_refused_but_absent_is_fine() {
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch_rectangle(s: &mut Session) {
|
||||
pub(super) fn sketch_rectangle(s: &mut Session) {
|
||||
for (id, p) in [
|
||||
("sketch.create", json!({"plane": "XY"})),
|
||||
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [10, 10]})),
|
||||
@@ -302,32 +259,12 @@ fn the_level_survives_a_new_design() {
|
||||
assert_eq!(s.scratch().param_check, ParamCheck::Strict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
|
||||
"removed_bodies": ["C"], "warning": warning, "ms": ms});
|
||||
let new = FeatureResult {
|
||||
feature: 7,
|
||||
name: "Box1".into(),
|
||||
bodies: vec!["A".into(), "B".into()],
|
||||
created_bodies: vec!["B".into()],
|
||||
changed_bodies: vec![],
|
||||
removed_bodies: vec!["C".into()],
|
||||
warning: warning.clone(),
|
||||
ms,
|
||||
param_warnings: vec![],
|
||||
};
|
||||
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` conforms to `node` of the schema `root`, for the keywords these schemas use
|
||||
/// apart from `pattern`: `type`, `properties`, `required`, `additionalProperties: false`,
|
||||
/// `items`, `minItems`, `maxItems`, `uniqueItems`, `enum`, `minLength`, `maxLength`, `minimum`,
|
||||
/// `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `anyOf`, `not` and `$ref`. (`integer`
|
||||
/// here is what serde reads as one, so `1.0` is not.)
|
||||
fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
|
||||
pub(super) fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
|
||||
if let Some(name) = node.get("$ref").and_then(Value::as_str).and_then(|r| r.strip_prefix("#/$defs/")) {
|
||||
return root.get("$defs").and_then(|d| d.get(name)).is_some_and(|t| conforms(root, t, v));
|
||||
}
|
||||
@@ -410,38 +347,6 @@ fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
|
||||
assert!(!required.contains(&"param_warnings"));
|
||||
let mut s = Session::default();
|
||||
sketch_rectangle(&mut s);
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let replies = [
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
|
||||
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
|
||||
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
|
||||
];
|
||||
for r in replies {
|
||||
let r = r.unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
}
|
||||
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
|
||||
// schema leaves other keys open, so a new result key doesn't break clients.)
|
||||
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
|
||||
assert!(conforms(schema, schema, &ok));
|
||||
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
|
||||
let mut bad = ok.clone();
|
||||
bad[k] = v;
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
let mut bad = ok.clone();
|
||||
bad.as_object_mut().map(|o| o.remove("name"));
|
||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn the_recorder_keeps_a_bounded_number_of_signatures() {
|
||||
@@ -459,22 +364,6 @@ fn the_recorder_keeps_a_bounded_number_of_signatures() {
|
||||
assert_eq!(seen.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_returns_both_schemas() {
|
||||
let mut s = Session::default();
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(d["id"], "solid.box");
|
||||
assert!(d["input"]["properties"].get("length").is_some());
|
||||
assert!(d["output"]["properties"].get("created_bodies").is_some());
|
||||
// Its own output schema lists the warnings warn mode can add.
|
||||
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
|
||||
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
|
||||
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
|
||||
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
|
||||
}
|
||||
|
||||
/// The profiles schema and the contract agree on every listed case; the schema is deliberately
|
||||
/// narrower on non-text curve ids and loops past the outer one (and describes the app's
|
||||
/// `whole_boundaries` selection only by its `type`).
|
||||
@@ -554,43 +443,29 @@ fn recorder_lines_have_the_documented_shape() {
|
||||
assert_eq!(super::record::enabled(), cfg!(feature = "param-audit") && set);
|
||||
}
|
||||
|
||||
/// A contract's input schema lists exactly `fields` (sorted) as its keys, refuses other keys
|
||||
/// and declares draft 2020-12. Each module's `schemas_and_the_key_check_agree` calls this for
|
||||
/// its contracts, so their keys change only on purpose. (Whether the keys are the ones the
|
||||
/// handler reads is what the audit run checks.)
|
||||
pub(super) fn assert_keys(schema: Schema, fields: &[&str]) {
|
||||
let v = schema.as_value();
|
||||
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
|
||||
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
|
||||
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
|
||||
}
|
||||
|
||||
/// A call that already fails gets the same error in strict as with the check off, apart from
|
||||
/// the missing-key, missing-instead-of-wrong-type, non-object, profile-index, 10,000-item and
|
||||
/// over-4-KB messages the report lists.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let cases = [
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
|
||||
("solid.box", json!({"length": true, "width": 5, "height": 5})),
|
||||
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
|
||||
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": []})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": []})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": null})),
|
||||
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
|
||||
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
|
||||
("solid.extrude", json!({"distance": 5, "thin": "true"})),
|
||||
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
|
||||
("solid.extrude", json!({"distance": 5, "from": "x"})),
|
||||
("solid.extrude", json!({"distance": 5, "sketch": true})),
|
||||
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
|
||||
("solid.extrude", json!({"distance": " "})),
|
||||
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
|
||||
("engine.describe", json!({"command": 5})),
|
||||
("engine.describe", json!({"command": null})),
|
||||
("engine.describe", json!({"command": "solid.extrud"})),
|
||||
];
|
||||
/// over-4-KB messages the report lists, and a list of picks where one finds nothing in the
|
||||
/// design and a later one is malformed (the handler fails on the first, strict on the second).
|
||||
/// Each module's `strict_keeps_todays_errors` calls this with its commands' cases; each runs
|
||||
/// after `sketch_rectangle`.
|
||||
pub(super) fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
|
||||
for (id, p) in cases {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
sketch_rectangle(&mut s);
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
s.execute(id, p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{p} should fail today");
|
||||
@@ -601,7 +476,10 @@ fn strict_keeps_todays_errors() {
|
||||
/// Every `default` in the input schemas is a value its own property schema allows.
|
||||
#[test]
|
||||
fn schema_defaults_are_valid() {
|
||||
for root in [input_schema_for::<BoxParams>(), input_schema_for::<ExtrudeParams>()] {
|
||||
let contracts: Vec<_> = crate::cmd::command_specs().into_iter().filter_map(|c| c.contract).collect();
|
||||
assert!(contracts.len() >= 3);
|
||||
for c in contracts {
|
||||
let root = c.input_schema();
|
||||
let root = root.as_value();
|
||||
for (key, property) in root["properties"].as_object().into_iter().flatten() {
|
||||
if let Some(d) = property.get("default") {
|
||||
@@ -631,3 +509,235 @@ fn long_values_in_handler_messages_are_capped() {
|
||||
let w = r["param_warnings"][0].as_str().unwrap_or_default();
|
||||
assert!(w.starts_with("unknown side `xxx") && w.len() <= 4096 + "…".len(), "{} bytes", w.len());
|
||||
}
|
||||
|
||||
/// The pick types accept what the pick readers accept. (Comparing a type with the helper it
|
||||
/// calls only guards against the two drifting apart; the schema comparisons and the handler
|
||||
/// tests below are the real checks.)
|
||||
#[test]
|
||||
fn face_and_edge_refs_match_the_pick_readers() {
|
||||
let long = "f".repeat(513);
|
||||
// 171 three-byte characters are 513 bytes, too long for the reader; the schema's maxLength
|
||||
// counts characters, so it can't say this.
|
||||
let wide = "面".repeat(171);
|
||||
let face_schema = input_schema_for::<FaceRef>();
|
||||
let face_schema = face_schema.as_value();
|
||||
let edge_schema = input_schema_for::<EdgeRef>();
|
||||
let edge_schema = edge_schema.as_value();
|
||||
for v in [
|
||||
json!([1, 2, 3]),
|
||||
json!({"x": 1, "y": 2, "z": 3}),
|
||||
json!({"point": [1, 2, 3]}),
|
||||
json!({"point": {"x": 1, "y": 2, "z": 3}}),
|
||||
json!({"x": "a", "point": [1, 2, 3]}),
|
||||
json!("F3:1"),
|
||||
json!(""),
|
||||
json!(long),
|
||||
json!([1, 2]),
|
||||
json!([1e9, 0, 0]),
|
||||
json!({"point": [1, 2]}),
|
||||
json!({"body": "A", "index": 0}),
|
||||
json!({"body": "A", "index": u64::MAX}),
|
||||
json!({"body": "A", "index": -1}),
|
||||
json!({"body": 5, "index": 0}),
|
||||
json!({"body": "A"}),
|
||||
json!({"point": "x", "body": "A", "index": 2}),
|
||||
json!(null),
|
||||
json!(5),
|
||||
json!(true),
|
||||
] {
|
||||
let face = crate::cmd::face_ref_form(&v).is_ok();
|
||||
assert_eq!(parse::<FaceRef>(v.clone()).is_some(), face, "{v}");
|
||||
assert_eq!(conforms(face_schema, face_schema, &v), face, "face {v}");
|
||||
let edge = crate::cmd::edge_ref_form(&v).is_ok();
|
||||
assert_eq!(parse::<EdgeRef>(v.clone()).is_some(), edge, "{v}");
|
||||
assert_eq!(conforms(edge_schema, edge_schema, &v), edge, "edge {v}");
|
||||
}
|
||||
// Where the schemas can't follow the readers: a name's cap is in bytes, and an index of
|
||||
// `1.0`, which the reader refuses, is an integer to JSON Schema (`conforms` reads integers
|
||||
// as serde does, so it agrees with the reader here; a standard validator would not).
|
||||
assert!(parse::<FaceRef>(json!(wide)).is_none() && conforms(face_schema, face_schema, &json!(wide)));
|
||||
assert!(parse::<EdgeRef>(json!({"body": "A", "index": 1.0})).is_none());
|
||||
assert_eq!(edge_schema["anyOf"][2]["properties"]["index"]["type"], "integer");
|
||||
assert_eq!(parse::<EdgeRef>(json!({"body": "A", "index": 4})), Some(EdgeRef::Index { body: "A".into(), index: 4 }));
|
||||
assert_eq!(parse::<FaceRef>(json!({"point": [1, 2, 3]})), Some(FaceRef::Point(solidcraft_geom::Vec3::new(1.0, 2.0, 3.0))));
|
||||
}
|
||||
|
||||
/// The pick lists take 1 to 1000 picks, as `face_points` and `edge_points` do, and so do their
|
||||
/// schemas.
|
||||
#[test]
|
||||
fn pick_lists_match_the_list_readers() {
|
||||
let faces = input_schema_for::<FaceRefs>();
|
||||
let faces = faces.as_value();
|
||||
let edges = input_schema_for::<EdgeRefs>();
|
||||
let edges = edges.as_value();
|
||||
let point = json!([1, 2, 3]);
|
||||
for (v, accepted) in [
|
||||
(json!([point]), true),
|
||||
(json!(vec![point.clone(); 1000]), true),
|
||||
(json!([point, {"point": [4, 5, 6]}]), true),
|
||||
(json!([]), false),
|
||||
(json!(vec![point.clone(); 1001]), false),
|
||||
(json!([point, [1, 2]]), false),
|
||||
(json!(point), false),
|
||||
(json!(null), false),
|
||||
(json!("F1"), false),
|
||||
] {
|
||||
assert_eq!(parse::<FaceRefs>(v.clone()).is_some(), accepted, "{v}");
|
||||
assert_eq!(conforms(faces, faces, &v), accepted, "faces {v}");
|
||||
assert_eq!(parse::<EdgeRefs>(v.clone()).is_some(), accepted, "{v}");
|
||||
assert_eq!(conforms(edges, edges, &v), accepted, "edges {v}");
|
||||
}
|
||||
// Names are faces, not edges; `{body, index}` is an edge, not a face.
|
||||
let mixed = json!(["F1", {"body": "A", "index": 0}]);
|
||||
assert!(parse::<FaceRefs>(json!(["F1"])).is_some() && parse::<EdgeRefs>(json!(["F1"])).is_none());
|
||||
assert!(parse::<FaceRefs>(mixed.clone()).is_none() && !conforms(faces, faces, &mixed));
|
||||
assert_eq!(parse::<EdgeRefs>(json!([{"body": "A", "index": 0}])).map(|e| e.0.len()), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sketch_refs_match_sketch_id() {
|
||||
let schema = input_schema_for::<SketchRef>();
|
||||
let schema = schema.as_value();
|
||||
for v in [json!(3), json!(2.5), json!(-1), json!("Profile"), json!(""), json!(null), json!(true), json!([1]), json!({"id": 1})] {
|
||||
let sketch = crate::cmd::sketch_key(Some(&v));
|
||||
assert_eq!(parse::<SketchRef>(v.clone()).map(|s| s.0), sketch, "{v}");
|
||||
assert_eq!(conforms(schema, schema, &v), sketch.is_some(), "{v}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A contract built from the shared types, as a converted feature command's would be: the pick
|
||||
/// keys refuse `null`, as their readers do.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
#[allow(dead_code)]
|
||||
struct Probe {
|
||||
radius: LengthExpr,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "FaceRefs")]
|
||||
faces: Option<FaceRefs>,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "EdgeRefs")]
|
||||
edges: Option<EdgeRefs>,
|
||||
#[serde(default, deserialize_with = "present")]
|
||||
#[schemars(with = "SketchRef")]
|
||||
path_sketch: Option<SketchRef>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
common: FeatureCommon,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_common_flattens_into_a_contract() {
|
||||
let c = TypedContract::<Probe>::new();
|
||||
assert_keys(input_schema_for::<Probe>(), &["body_name", "body_names", "edges", "faces", "name", "participants", "path_sketch", "radius"]);
|
||||
let ok = c.check(&json!({"radius": 1, "name": "Round", "body_name": "B", "body_names": ["B", "C"], "participants": "A"}));
|
||||
assert!(ok.is_empty(), "{ok:?}");
|
||||
assert!(c.check(&json!({"radius": 1, "name": null, "participants": null})).is_empty(), "null is absent, as for `str_`");
|
||||
let parsed: Option<Probe> = parse(json!({"radius": 1, "name": "Round", "participants": ["A", "B"]}));
|
||||
assert_eq!(parsed.map(|p| p.common.participants), Some(Some(StringList(vec!["A".into(), "B".into()]))));
|
||||
// Messages and paths name the key, as for the parent's own fields.
|
||||
for (p, path, message) in [
|
||||
(json!({"radius": 1, "name": 5}), "name", "`name` must be text"),
|
||||
(json!({"radius": 1, "body_name": [1]}), "body_name", "`body_name` must be text"),
|
||||
(json!({"radius": 1, "participants": 5}), "participants", "`participants` must be a name or a list of names"),
|
||||
(json!({"radius": 1, "body_names": vec!["x"; 10_001]}), "body_names", "`body_names` must have at most 10000 items"),
|
||||
(json!({"radius": 1, "bodyname": "x"}), "bodyname", "unknown key `bodyname`; did you mean `body_name`?"),
|
||||
(json!({"radius": 1, "faces": null}), "faces", "`faces` must be a list of points or face names"),
|
||||
(json!({"radius": 1, "edges": null}), "edges", "`edges` must be a list"),
|
||||
(json!({"radius": 1, "path_sketch": null}), "path_sketch", "`path_sketch` must be a sketch id or name"),
|
||||
(json!({"name": "x"}), "radius", "`radius` is required"),
|
||||
// The flattened keys are read after the parent's own, whatever their order.
|
||||
(json!({"name": 5}), "radius", "`radius` is required"),
|
||||
(json!({"body_name": 5, "radius": true}), "radius", "`radius` must be a number or an expression"),
|
||||
] {
|
||||
let issues = c.check(&p);
|
||||
assert_eq!(messages(&issues), vec![message], "{p}");
|
||||
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A flattened `FeatureCommon`'s errors are located below wherever its parent is.
|
||||
#[test]
|
||||
fn feature_common_errors_keep_their_place() {
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
#[allow(dead_code)]
|
||||
struct Outer {
|
||||
options: Probe,
|
||||
#[serde(default)]
|
||||
list: Vec<Probe>,
|
||||
}
|
||||
let c = TypedContract::<Outer>::new();
|
||||
let probe = json!({"radius": 1});
|
||||
for (p, path, message) in [
|
||||
(json!({"options": {"radius": 1, "name": 5}}), "options.name", "`options.name` must be text"),
|
||||
(
|
||||
json!({"options": probe, "list": [probe, {"radius": 1, "participants": 5}]}),
|
||||
"list[1].participants",
|
||||
"`list[1].participants` must be a name or a list of names",
|
||||
),
|
||||
(json!({"options": probe, "list": [{"radius": 1, "faces": []}]}), "list[0].faces", "select 1…1000 faces"),
|
||||
] {
|
||||
let issues = c.check(&p);
|
||||
assert_eq!(messages(&issues), vec![message], "{p}");
|
||||
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pick types refuse what the handlers refuse, with the handlers' messages.
|
||||
#[test]
|
||||
fn shared_types_keep_the_handlers_messages() {
|
||||
let c = TypedContract::<Probe>::new();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
sketch_rectangle(&mut s);
|
||||
for (id, p, probe) in [
|
||||
("solid.shell", json!({"thickness": 1, "faces": 5}), json!({"faces": 5})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": null}), json!({"faces": null})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": []}), json!({"faces": []})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": [true]}), json!({"faces": [true]})),
|
||||
("solid.shell", json!({"thickness": 1, "faces": [""]}), json!({"faces": [""]})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": {}}), json!({"edges": {}})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": vec![[0, 0, 0]; 1001]}), json!({"edges": vec![[0, 0, 0]; 1001]})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": [[1, 2]]}), json!({"edges": [[1, 2]]})),
|
||||
("solid.fillet", json!({"radius": 1, "edges": [{"body": "Body1", "index": "0"}]}), json!({"edges": [{"body": "Body1", "index": "0"}]})),
|
||||
("solid.sweep", json!({"path_sketch": true, "path": ["l1"]}), json!({"path_sketch": true})),
|
||||
("solid.sweep", json!({"path_sketch": null, "path": ["l1"]}), json!({"path_sketch": null})),
|
||||
] {
|
||||
let mut probe = probe;
|
||||
probe["radius"] = json!(1);
|
||||
let issue = messages(&c.check(&probe)).into_iter().next().unwrap_or_default();
|
||||
let today = s.execute(id, &p).err().map(|e| e.to_string());
|
||||
assert_eq!(today, Some(crate::params::bad(id, issue).to_string()), "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The lookups that follow the shape checks the pick types share with the handlers: picks
|
||||
/// by `{body, index}` and sketches by name still find the design, and still fail in order, one
|
||||
/// pick at a time.
|
||||
#[test]
|
||||
fn picks_are_still_looked_up_one_at_a_time() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let error = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let fillet = |edges: Value| json!({"radius": 1, "edges": edges});
|
||||
assert_eq!(
|
||||
error(&mut s, "solid.fillet", fillet(json!([{"body": "Nope", "index": 0}, [1, 2]]))),
|
||||
"invalid parameters for `solid.fillet`: no body `Nope`"
|
||||
);
|
||||
assert_eq!(
|
||||
error(&mut s, "solid.fillet", fillet(json!([{"body": "Body1", "index": 999}]))),
|
||||
"invalid parameters for `solid.fillet`: Body1 has no edge 999"
|
||||
);
|
||||
let far = error(&mut s, "solid.fillet", fillet(json!([[100, 100, 100], [1, 2]])));
|
||||
assert!(far.starts_with("invalid parameters for `solid.fillet`: no edge at [100, 100, 100]"), "{far}");
|
||||
let unknown = error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace"]}));
|
||||
assert!(!unknown.is_empty());
|
||||
assert_eq!(error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace", true]})), unknown, "the name is looked up first");
|
||||
sketch_rectangle(&mut s);
|
||||
assert_eq!(
|
||||
error(&mut s, "solid.sweep", json!({"path_sketch": "Nope", "path": ["l1"]})),
|
||||
"invalid parameters for `solid.sweep`: no sketch `Nope`"
|
||||
);
|
||||
assert_eq!(error(&mut s, "solid.extrude", json!({"distance": 5, "sketch": "Nope"})), "invalid parameters for `solid.extrude`: no sketch `Nope`");
|
||||
let r = s.execute("solid.fillet", &fillet(json!([{"body": "Body1", "index": 0}]))).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}");
|
||||
}
|
||||
|
||||
@@ -583,3 +583,201 @@ impl JsonSchema for ProfilesParam {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A face given to a command, as `face::face_ref` reads it: a point on the face (`[x, y, z]`,
|
||||
/// `{x, y, z}` or `{point}`) or its name as `model.faces` lists it (1 to 512 bytes; the
|
||||
/// schema's `maxLength` counts characters, so it can't say bytes). Whether it finds a face is
|
||||
/// checked by the handler, which has the design. (Not `solidcraft_doc::FacePick`, the stored
|
||||
/// pick.)
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FaceRef {
|
||||
Point(Vec3),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FaceRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match crate::cmd::face_ref_form(&Value::deserialize(d)?) {
|
||||
Ok(crate::cmd::FaceRefForm::Point(p)) => Ok(FaceRef::Point(p)),
|
||||
Ok(crate::cmd::FaceRefForm::Name(n)) => Ok(FaceRef::Name(n.to_string())),
|
||||
Err(why) => Err(D::Error::custom(whole(why))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for FaceRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"FaceRef".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A face: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or its name as model.faces lists it (at most 512 bytes of UTF-8).",
|
||||
"anyOf": [
|
||||
point,
|
||||
{"type": "object", "properties": {"point": point}, "required": ["point"]},
|
||||
{"type": "string", "minLength": 1, "maxLength": 512}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An edge given to a fillet or chamfer, as `features::edge_points` reads it: a point on the
|
||||
/// edge (`[x, y, z]`, `{x, y, z}` or `{point}`), else `{body, index}` in the body's edge order.
|
||||
/// The index is whole-number JSON, as `Value::as_u64` reads it: JSON Schema also counts `1.0`
|
||||
/// as an integer, which the reader refuses. Whether it finds an edge is checked by the handler,
|
||||
/// which has the design. (Not `solidcraft_doc::EdgeRef`, the stored edge position.)
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum EdgeRef {
|
||||
Point(Vec3),
|
||||
Index { body: String, index: u64 },
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EdgeRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match crate::cmd::edge_ref_form(&Value::deserialize(d)?) {
|
||||
Ok(crate::cmd::EdgeRefForm::Point(p)) => Ok(EdgeRef::Point(p)),
|
||||
Ok(crate::cmd::EdgeRefForm::Index { body, index }) => Ok(EdgeRef::Index { body: body.to_string(), index }),
|
||||
Err(why) => Err(D::Error::custom(whole(why))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for EdgeRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"EdgeRef".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "An edge: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or {\"body\", \"index\"} in the body's edge order (an index written without a fraction).",
|
||||
"anyOf": [
|
||||
point,
|
||||
{"type": "object", "properties": {"point": point}, "required": ["point"]},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"body": {"type": "string"}, "index": {"type": "integer", "minimum": 0, "maximum": u64::MAX}},
|
||||
"required": ["body", "index"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A list of 1 to 1000 picks, with the handler's messages for the list itself. Like the handler,
|
||||
/// it refuses `null`: an optional field of this type takes `present`.
|
||||
///
|
||||
/// The handler looks each pick up in the design before reading the next, so a call with a pick
|
||||
/// that finds nothing and a later malformed one fails today on the first and in strict on the
|
||||
/// second.
|
||||
macro_rules! pick_list {
|
||||
($(#[$doc:meta])* $name:ident, $item:ty, $not_list:literal, $count:literal, $description:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct $name(pub Vec<$item>);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let items = v.as_array().ok_or_else(|| D::Error::custom(whole($not_list)))?;
|
||||
if items.is_empty() || items.len() > 1000 {
|
||||
return Err(D::Error::custom(whole($count)));
|
||||
}
|
||||
items.iter().map(|item| <$item>::deserialize(item).map_err(D::Error::custom)).collect::<Result<_, _>>().map($name)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let item = generator.subschema_for::<$item>();
|
||||
json_schema!({"description": $description, "type": "array", "items": item, "minItems": 1, "maxItems": 1000})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pick_list!(
|
||||
/// The `faces` of `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face` and
|
||||
/// `face.delete`, as `features::face_points` reads them. (Extrude's `faces` is a
|
||||
/// [`FacesParam`].)
|
||||
FaceRefs,
|
||||
FaceRef,
|
||||
"`faces` must be a list of points or face names",
|
||||
"select 1…1000 faces",
|
||||
"The faces, 1 to 1000."
|
||||
);
|
||||
pick_list!(
|
||||
/// The `edges` of `solid.fillet` and `solid.chamfer`, as `features::edge_points` reads them.
|
||||
EdgeRefs,
|
||||
EdgeRef,
|
||||
"`edges` must be a list",
|
||||
"select 1…1000 edges",
|
||||
"The edges, 1 to 1000."
|
||||
);
|
||||
|
||||
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
|
||||
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
|
||||
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
|
||||
/// where the handler says `sections[].sketch`. The `sketch` that `features::feature_sketch`
|
||||
/// reads (extrude's, revolve's…) has another message and is an [`IdOrName`]. Where the reader
|
||||
/// refuses `null`, an optional field of this type takes `present`. Whether a sketch has that id
|
||||
/// or name is checked by the handler.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SketchRef(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SketchRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
crate::cmd::sketch_key(Some(&Value::deserialize(d)?)).map(SketchRef).ok_or_else(|| D::Error::custom("must be a sketch id or name"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchRef".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A sketch's feature id (a number) or its name.", "type": ["number", "string"]})
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys `features::add_feature` reads for every feature command, whatever else it takes:
|
||||
/// the feature's name, the new bodies' names and the bodies taking part. A feature command's
|
||||
/// parameters include it with
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[serde(flatten, deserialize_with = "feature_common")]
|
||||
/// pub common: FeatureCommon,
|
||||
/// ```
|
||||
///
|
||||
/// Two keys `add_feature` also reads aren't here. `targets` it reads only for primitives; the
|
||||
/// other commands that read it are the ones that combine bodies. `operation` it reads for every
|
||||
/// feature (a cut where the active component has no bodies takes part in all of them), but only
|
||||
/// the commands that combine bodies take one; those declare it with their own type
|
||||
/// ([`OperationParam`] or [`ExtrudeOperation`]), and for the rest it is a caller's mistake.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
|
||||
pub struct FeatureCommon {
|
||||
/// The feature's name in the timeline.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// The new body's name.
|
||||
#[serde(default)]
|
||||
pub body_name: Option<String>,
|
||||
/// Names for the new bodies, in order.
|
||||
#[serde(default)]
|
||||
pub body_names: Option<StringList>,
|
||||
/// The bodies taking part; overrides the default scope.
|
||||
#[serde(default)]
|
||||
pub participants: Option<StringList>,
|
||||
}
|
||||
|
||||
/// Deserializes a flattened [`FeatureCommon`] from the keys the parent doesn't take, and reports
|
||||
/// a problem as if its fields were the parent's own, with the key in the message and in the
|
||||
/// issue's path (serde's flatten otherwise loses the key). serde reads a flattened field after
|
||||
/// all the parent's own, so its problems come after theirs, including a missing key.
|
||||
pub fn feature_common<'de, D: Deserializer<'de>>(d: D) -> Result<FeatureCommon, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e)))
|
||||
}
|
||||
|
||||
@@ -1785,6 +1785,23 @@ fn checkpoint_restores_with_a_full_undo_history() {
|
||||
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
|
||||
}
|
||||
|
||||
/// Regression (#3242): `file.new` replaced the session and its checkpoints with it, so a
|
||||
/// batch that failed after `file.new` could not go back to the design it began in.
|
||||
#[test]
|
||||
fn a_checkpoint_survives_file_new() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 1.0, "width": 1.0, "height": 1.0}));
|
||||
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
|
||||
run(&mut s, "file.new", json!({"name": "Replacement"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2.0, "width": 2.0, "height": 2.0}));
|
||||
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(8.0));
|
||||
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
|
||||
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(1.0));
|
||||
// The undo history is the restored design's own: one step back is the empty design.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_occurrences_and_world_placement() {
|
||||
let mut s = Session::default();
|
||||
@@ -3015,6 +3032,40 @@ fn shell_directions_and_tangent_chain() {
|
||||
assert!(s.execute("solid.shell", &json!({"faces": [[20, 15, 20]], "thickness": 2, "direction": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// #209: selecting a tangent chain must explain why no shell can remain.
|
||||
#[test]
|
||||
fn shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 60}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.fillet",
|
||||
json!({
|
||||
"edges": [[0,0,30], [40,0,30], [40,30,30], [0,30,30],
|
||||
[20,0,60], [20,30,60], [0,15,60], [40,15,60],
|
||||
[20,0,0], [20,30,0], [0,15,0], [40,15,0]],
|
||||
"radius": 1.5
|
||||
}),
|
||||
);
|
||||
let before = s.doc.to_json();
|
||||
for direction in ["inside", "outside", "both"] {
|
||||
let error = s
|
||||
.execute(
|
||||
"solid.shell",
|
||||
&json!({
|
||||
"faces": [[20,0,30]], "thickness": 2, "tangent_chain": true, "direction": direction
|
||||
}),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Tangent Chain") && error.contains("every face") && error.contains("turn off"), "{direction}: {error}");
|
||||
assert_eq!(s.doc.to_json(), before, "a refused shell must preserve the design");
|
||||
}
|
||||
run(&mut s, "solid.shell", json!({"faces": [[20,0,30]], "thickness": 2, "tangent_chain": false}));
|
||||
assert!((volume(&mut s) - 15_305.28).abs() < 0.1);
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// Hole To Object (a face it meets, or a point) and holes at every point of a sketch.
|
||||
#[test]
|
||||
fn hole_to_object_and_at_sketch_points() {
|
||||
|
||||
@@ -170,8 +170,8 @@ fn wrong_up_and_names_are_refused() {
|
||||
assert_eq!(kinds(&s), ["BoxFeature", "BaseFeature"]);
|
||||
}
|
||||
|
||||
/// `names` says what the bodies are called: the solid's own name (the default), the part's, or
|
||||
/// the assembly occurrence's. Repeated names are numbered as ever.
|
||||
/// Explicit naming choices select the solid, part or occurrence; automatic naming keeps
|
||||
/// meaningful solid names.
|
||||
#[test]
|
||||
fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
|
||||
let path = frame_file("names");
|
||||
@@ -185,6 +185,7 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
|
||||
// The post is placed twice: one component, two occurrences, its body built once (#1386).
|
||||
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
|
||||
assert_eq!(named(json!({"path": path})), solids);
|
||||
assert_eq!(named(json!({"path": path, "names": "auto"})), solids);
|
||||
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
|
||||
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
|
||||
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
|
||||
@@ -228,3 +229,29 @@ fn a_step_file_can_be_inserted_at_a_matrix() {
|
||||
assert!(e.contains("not rigid"), "{e}");
|
||||
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
|
||||
}
|
||||
|
||||
/// Generated body labels become part names, and the saved assembly retains its components
|
||||
/// and repeated placements without needing the source file.
|
||||
#[test]
|
||||
fn automatic_part_names_and_assembly_survive_reopening() {
|
||||
let path = frame_file("automatic");
|
||||
let text = std::fs::read_to_string(&path).unwrap().replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
assert_eq!(body_names(&s), ["Plate", "Post"]);
|
||||
let top = r["component"].as_u64().unwrap();
|
||||
let parts: Vec<_> = s.doc.components.iter().filter(|c| c.parent == top).collect();
|
||||
assert_eq!(parts.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post"]);
|
||||
assert_eq!(s.doc.occurrences.iter().filter(|o| o.component == parts[1].id && o.parent == top).count(), 2);
|
||||
let design = std::path::Path::new(&path).with_file_name("automatic.solidcraft");
|
||||
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let mut again = Session::default();
|
||||
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
|
||||
assert_eq!(body_names(&again), ["Plate", "Post"]);
|
||||
assert_eq!(again.doc.components, s.doc.components);
|
||||
assert_eq!(again.doc.occurrences, s.doc.occurrences);
|
||||
assert!(near(extent(&again).0, [0.0, 0.0, 0.0]) && near(extent(&again).1, [40.0, 34.0, 20.0]));
|
||||
assert!((total_volume(&mut again) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -92,6 +93,89 @@ fn modelled_full_length_fine_threads_on_large_shafts() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A modelled thread on a shaft thicker than it turns the shaft down to the major diameter
|
||||
/// over the threaded length, then cuts the thread: an M6 over the top 8 mm of an 8 mm shaft
|
||||
/// leaves a 6 mm threaded section on an 8 mm stub, its turned face named for later features.
|
||||
/// A shaft of the thread's own size is cut as it is.
|
||||
#[test]
|
||||
fn a_shaft_thicker_than_a_modelled_thread_is_turned_down_to_it() {
|
||||
let (p, len) = (1.0, 8.0);
|
||||
let root = 3.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
||||
let groove = per_turn(root, 3.0, |r| p / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * len / p;
|
||||
for (shaft, turned) in [(8.0, true), (6.0, false)] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": shaft / 2.0, "height": 12, "body_name": "Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face": [shaft / 2.0, 0, 11], "designation": "M6", "length": len, "modeled": true}));
|
||||
let ring = PI * (shaft * shaft / 4.0 - 9.0) * len;
|
||||
let want = PI * shaft * shaft / 4.0 * 12.0 - ring - groove;
|
||||
let got = volume(&mut s, "Shaft");
|
||||
assert!((got - want).abs() < groove * 0.03, "M6 on {shaft} mm: {got} mm³, want {want}");
|
||||
let st = s.model.state();
|
||||
let Some(b) = st.body("Shaft") else { panic!("no shaft") };
|
||||
assert!(b.body.validity().is_empty(), "{shaft} mm: {:?}", b.body.validity());
|
||||
// Nothing past the major diameter over the threaded length; the stub keeps its size.
|
||||
let round = |z: f64, r: f64| {
|
||||
(0..24).filter(|i| b.body.contains(Vec3::new(r * (PI * *i as f64 / 12.0).cos(), r * (PI * *i as f64 / 12.0).sin(), z))).count()
|
||||
};
|
||||
assert_eq!(round(8.0, 3.05), 0, "{shaft} mm: the threaded part is turned to 6 mm");
|
||||
assert_eq!(round(2.0, shaft / 2.0 - 0.05), 24, "{shaft} mm: the stub is whole");
|
||||
let names = solidcraft_doc::naming::face_names(b);
|
||||
let fid = s.doc.features.last().map(|f| f.id).unwrap_or(0);
|
||||
let named = |role: &str| names.iter().any(|n| n.starts_with(&format!("F{fid}:{role}")));
|
||||
assert_eq!(named("turned"), turned, "{shaft} mm: {names:?}");
|
||||
assert_eq!(named("shoulder:"), turned, "{shaft} mm: {names:?}");
|
||||
}
|
||||
|
||||
// A 9 mm shaft is close enough to M8 to be offered it; it was refused as too thick (#2688).
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
|
||||
run(&mut s, "solid.thread", json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true}));
|
||||
let root = 4.0 - 5.0 * 1.25 * 3f64.sqrt() / 16.0;
|
||||
let groove = per_turn(root, 4.0, |r| 1.25 / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * 10.0 / 1.25;
|
||||
let (want, got) = (PI * 16.0 * 10.0 - groove, volume(&mut s, "Shaft"));
|
||||
assert!((got - want).abs() < groove * 0.03, "M8 on 9 mm: {got} mm³, want {want}");
|
||||
}
|
||||
|
||||
/// Issue 2705: an M14 over the top 10 mm of a 14.6 mm boss on a plate. Turned to 14 mm first:
|
||||
/// 1600 + π 7.3² 15 − π (7.3² − 7²) 10 − 5 turns of 50.189 mm³ (tests/kernel-cases/threads/2705).
|
||||
#[test]
|
||||
fn an_m14_thread_on_a_14_6_mm_boss_turns_it_to_14_mm() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 4, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 7.3, "height": 15, "base": [0, 0, 4], "operation": "join", "targets": ["Part"]}));
|
||||
run(&mut s, "solid.thread", json!({"face": [7.3, 0, 17], "designation": "M14", "length": 10, "modeled": true}));
|
||||
let got = volume(&mut s, "Part");
|
||||
assert!((got - 3725.5127).abs() < 3725.5127 * 0.01, "{got} mm³");
|
||||
}
|
||||
|
||||
/// A shaft thinner than the thread's minor diameter is still refused, and so is a cosmetic
|
||||
/// thread on a shaft thicker than it (a note changes nothing to turn). A shaft the part goes on
|
||||
/// round (standing against a wall) is not turned into the wall.
|
||||
#[test]
|
||||
fn a_thin_shaft_or_one_the_part_surrounds_is_refused() {
|
||||
let refused = |s: &mut Session, body: &str, p: Value| {
|
||||
let before = volume(s, body);
|
||||
let e = s.execute("solid.thread", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!((volume(s, body) - before).abs() < 1e-9, "{p}: {e}");
|
||||
e
|
||||
};
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 10, "body_name": "Shaft"}));
|
||||
let e = refused(&mut s, "Shaft", json!({"face": [2.4, 0, 5], "designation": "M6", "modeled": true}));
|
||||
assert!(e.contains("does not fit a 4.80 mm shaft") || e.contains("too thin"), "{e}");
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "body_name": "Shaft"}));
|
||||
let e = refused(&mut s, "Shaft", json!({"face": [4, 0, 5], "designation": "M6"}));
|
||||
assert!(e.contains("an M6 thread does not fit a 8.00 mm shaft"), "{e}");
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"corner": [2, -10, 0], "length": 10, "width": 20, "height": 10, "body_name": "Part"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "operation": "join", "targets": ["Part"]}));
|
||||
let e = refused(&mut s, "Part", json!({"face": [-4, 0, 5], "designation": "M6", "modeled": true}));
|
||||
assert!(e.contains("this 8 mm shaft is too thick for a modelled M6 thread") && e.contains("part around it"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_thread_designations_are_refused_without_changing_the_design() {
|
||||
let mut s = Session::default();
|
||||
@@ -174,9 +258,9 @@ fn a_modelled_thread_that_would_cut_nothing_is_refused() {
|
||||
|
||||
/// Issue 2688: an M8 modelled thread in a 6 mm hole (narrower than its 6.65 mm minor diameter)
|
||||
/// was refused as "the face is too far from the thread's diameter". It says what is wrong and
|
||||
/// what to drill, as a hole that is too wide does; and the same for a shaft that is too thick.
|
||||
/// what to drill, as a hole that is too wide does. (A shaft too thick is turned down.)
|
||||
#[test]
|
||||
fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
|
||||
fn a_hole_too_narrow_says_so_and_what_fits() {
|
||||
let mut s = hex_nut(6.0);
|
||||
let before = volume(&mut s, "Nut");
|
||||
let e = s.execute("solid.thread", &json!({"face": [3.0, 0, 2], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
|
||||
@@ -184,13 +268,6 @@ fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
|
||||
assert!(e.contains("this 6 mm hole is too narrow for a modelled M8 thread") && e.contains("drill 6.75 mm for M8"), "{e}");
|
||||
assert!(!e.contains("too far"), "{e}");
|
||||
assert!((volume(&mut s, "Nut") - before).abs() < 1e-9);
|
||||
|
||||
let mut s = Session::default();
|
||||
// 9 mm is close enough to M8 to be offered it, too thick to cut it.
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
|
||||
let e = s.execute("solid.thread", &json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
|
||||
let e = e.unwrap_or_default();
|
||||
assert!(e.contains("this 9 mm shaft is too thick for a modelled M8 thread") && e.contains("make the shaft 8 mm for M8"), "{e}");
|
||||
}
|
||||
|
||||
/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for
|
||||
|
||||
+13
-1
@@ -8,6 +8,9 @@ pub mod envelope;
|
||||
pub const VERSION: u32 = 3;
|
||||
pub const MAX_ITEMS: usize = 1_000_000;
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// Most control points along one direction of a spline (a helical thread flank written as one
|
||||
/// spline has thousands along the helix); the item budget bounds a surface's grid as a whole.
|
||||
pub const MAX_SPLINE_CONTROLS: usize = 8192;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -195,7 +198,7 @@ fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
|
||||
budget.add(values.len())?;
|
||||
numbers(values.iter().copied())?;
|
||||
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
|
||||
require((2..=4096).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require((2..=MAX_SPLINE_CONTROLS).contains(&count) && (1..=16).contains(°ree) && degree < count, "spline degree or size")?;
|
||||
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
|
||||
require(values.get(degree) < values.get(count), "empty spline domain")?;
|
||||
// No knot may have multiplicity greater than degree + 1.
|
||||
@@ -482,6 +485,15 @@ mod tests {
|
||||
assert!(surface(&p, 0, &mut Budget(0)).is_err());
|
||||
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
|
||||
assert!(Budget(MAX_ITEMS).add(1).is_err());
|
||||
// Control points along one direction: up to the cap, not one more (a clamped cubic).
|
||||
let clamped = |count: usize| {
|
||||
let mut k = vec![0.0; 4];
|
||||
k.extend((1..count - 3).map(|i| i as f64));
|
||||
k.extend([(count - 3) as f64; 4]);
|
||||
k
|
||||
};
|
||||
assert!(knots(&clamped(MAX_SPLINE_CONTROLS), MAX_SPLINE_CONTROLS, &mut Budget(0)).is_ok());
|
||||
assert!(knots(&clamped(MAX_SPLINE_CONTROLS + 1), MAX_SPLINE_CONTROLS + 1, &mut Budget(0)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+33
-6
@@ -36,7 +36,10 @@ pub enum EvalSurface {
|
||||
/// `origin + u·du + v·dv`.
|
||||
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
|
||||
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
|
||||
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]> },
|
||||
/// `closed[d]`: the surface joins itself along direction `d` (a B-spline tube's way round).
|
||||
/// Such a direction is periodic: a parameter outside the knot range is moved by whole
|
||||
/// ranges back into it, so a face whose boundary runs across the join still evaluates.
|
||||
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]>, closed: [bool; 2] },
|
||||
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
|
||||
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
|
||||
/// `swap` the parameters are exchanged first (the surface is evaluated at `(v, u)`).
|
||||
@@ -128,7 +131,7 @@ impl TessPlan {
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
EvalSurface::Plane { origin, du, dv } => EvalSurface::Plane { origin: point(*origin), du: vector(*du), dv: vector(*dv) },
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => EvalSurface::Nurbs {
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => EvalSurface::Nurbs {
|
||||
degree: *degree,
|
||||
knots: knots.clone(),
|
||||
count: *count,
|
||||
@@ -140,6 +143,9 @@ impl TessPlan {
|
||||
[q.x, q.y, q.z, c[3]]
|
||||
})
|
||||
.collect(),
|
||||
// Which directions the surface joins itself along is a property of its
|
||||
// parameters: moving the surface does not change it.
|
||||
closed: *closed,
|
||||
},
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
// `m` (as rows) after the surface's own affine map.
|
||||
@@ -235,7 +241,20 @@ impl EvalSurface {
|
||||
}
|
||||
match self {
|
||||
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => nurbs_derivs(*degree, knots, *count, ctrl, u, v),
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
|
||||
// The knot range of direction `d`: the parameters the surface is defined over.
|
||||
let range = |d: usize| -> Option<(f64, f64)> {
|
||||
let k = knots.get(d)?;
|
||||
Some((*k.get(*degree.get(d)?)?, *k.get(*count.get(d)?)?))
|
||||
};
|
||||
// A direction the surface joins itself along repeats, so a parameter outside the
|
||||
// range is moved by whole ranges back into it. Any other direction is left alone.
|
||||
let wrap = |d: usize, t: f64| match range(d) {
|
||||
Some((a, b)) if closed.get(d).copied().unwrap_or(false) && b > a && !(a..=b).contains(&t) => a + (t - a).rem_euclid(b - a),
|
||||
_ => t,
|
||||
};
|
||||
nurbs_derivs(*degree, knots, *count, ctrl, wrap(0, u), wrap(1, v))
|
||||
}
|
||||
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
|
||||
let (s, a) = if *swap { (v, u) } else { (u, v) };
|
||||
let (c, dc) = profile.eval(s)?;
|
||||
@@ -390,6 +409,7 @@ mod tests {
|
||||
knots: [vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [3, 2],
|
||||
ctrl: vec![[1.0, 0.0, 0.0, 1.0], [w, w, 0.0, w], [0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 2.0, 1.0], [w, w, 2.0 * w, w], [0.0, 1.0, 2.0, 1.0]],
|
||||
closed: [false, false],
|
||||
};
|
||||
let revolved = EvalSurface::Revolved {
|
||||
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 0.0, 1.0)),
|
||||
@@ -511,7 +531,13 @@ mod tests {
|
||||
fn bilinear_nurbs_patch_and_derivatives() {
|
||||
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
|
||||
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
|
||||
let s = EvalSurface::Nurbs { degree: [1, 1], knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], count: [2, 2], ctrl };
|
||||
let s = EvalSurface::Nurbs {
|
||||
degree: [1, 1],
|
||||
knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [2, 2],
|
||||
ctrl,
|
||||
closed: [false, false],
|
||||
};
|
||||
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
|
||||
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
|
||||
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
|
||||
@@ -533,7 +559,7 @@ mod tests {
|
||||
}
|
||||
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
|
||||
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
|
||||
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl };
|
||||
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl, closed: [false, false] };
|
||||
for i in 0..=8 {
|
||||
for j in 0..=8 {
|
||||
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
|
||||
@@ -557,12 +583,13 @@ mod tests {
|
||||
#[test]
|
||||
fn hostile_surfaces_do_not_panic() {
|
||||
let bad = [
|
||||
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![] },
|
||||
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![], closed: [false, false] },
|
||||
EvalSurface::Nurbs {
|
||||
degree: [1, 1],
|
||||
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
|
||||
count: [2, 2],
|
||||
ctrl: vec![[0.0; 4]; 4],
|
||||
closed: [false, false],
|
||||
},
|
||||
EvalSurface::Revolved {
|
||||
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
|
||||
|
||||
@@ -466,8 +466,9 @@ fn pack(plans: &[&TessPlan]) -> Result<Packed> {
|
||||
push3(&mut data, *du);
|
||||
push3(&mut data, *dv);
|
||||
}
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl } => {
|
||||
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
|
||||
head[0] = 1;
|
||||
head[11] = u32::from(closed[0]) | (u32::from(closed[1]) << 1);
|
||||
head[1] = word(degree[0])?;
|
||||
head[2] = word(degree[1])?;
|
||||
head[3] = word(count[0])?;
|
||||
|
||||
@@ -27,7 +27,8 @@ struct Surface {
|
||||
// Revolved: parameters exchanged; mirror transform (normal reversed).
|
||||
swap: u32,
|
||||
mirror: u32,
|
||||
_pad: u32,
|
||||
// NURBS: bit 0 the surface joins itself along u, bit 1 along v (the direction is periodic).
|
||||
closed: u32,
|
||||
};
|
||||
|
||||
struct Vertex {
|
||||
@@ -159,8 +160,20 @@ fn rational_der(a: vec4<f32>, da: vec4<f32>) -> vec3<f32> {
|
||||
return (da.xyz - rational_point(a) * da.w) / a.w;
|
||||
}
|
||||
|
||||
fn nurbs(s: Surface, u: f32, v: f32) -> Derivs {
|
||||
// A parameter outside the knot range of a direction the surface joins itself along, moved by
|
||||
// whole ranges back into it (the CPU evaluator's `EvalSurface::derivs` does the same).
|
||||
fn wrap_closed(knots: u32, p: u32, n: u32, t: f32, closed: bool) -> f32 {
|
||||
if (!closed) { return t; }
|
||||
let a = data[knots + p];
|
||||
let b = data[knots + n];
|
||||
if (!(b > a) || (t >= a && t <= b)) { return t; }
|
||||
return a + (t - a) - floor((t - a) / (b - a)) * (b - a);
|
||||
}
|
||||
|
||||
fn nurbs(s: Surface, u_in: f32, v_in: f32) -> Derivs {
|
||||
var r: Derivs;
|
||||
let u = wrap_closed(s.knots_u, s.pu, s.nu, u_in, (s.closed & 1u) != 0u);
|
||||
let v = wrap_closed(s.knots_v, s.pv, s.nv, v_in, (s.closed & 2u) != 0u);
|
||||
let su = span_of(s.knots_u, s.pu, s.nu, u);
|
||||
let sv = span_of(s.knots_v, s.pv, s.nv, v);
|
||||
let bu0 = basis_funs(s.knots_u, su, s.pu, u);
|
||||
|
||||
+216
-35
@@ -20,7 +20,15 @@ const VERSION: &str = "solidcraft-container/1";
|
||||
const LEGACY_VERSION: &str = "solvecraft-container/1";
|
||||
const PREVIEW_VERSION: &str = "solidcraft-preview/1";
|
||||
const MAX_ENTRIES: usize = 10_000;
|
||||
const MAX_EXPANDED: usize = 512 << 20;
|
||||
/// An embedded STEP source may be as large as an import accepts. It is always stored
|
||||
/// Deflate-compressed, so the file itself stays within [`MAX_DESIGN_BYTES`] (#3221).
|
||||
const MAX_STEP_PART: usize = crate::MAX_STEP_BYTES;
|
||||
/// Deflate level for STEP sources. Level 1 uses fixed Huffman codes: a 270 MiB STEP export
|
||||
/// shrank to 70 MB in 1.6 s, at level 2 to 47 MB in 3.1 s; higher levels gain little more.
|
||||
const STEP_LEVEL: i64 = 2;
|
||||
/// Every other part is bounded by the file size limit.
|
||||
const MAX_PART: usize = MAX_DESIGN_BYTES;
|
||||
const MAX_EXPANDED: usize = MAX_STEP_PART + MAX_DESIGN_BYTES;
|
||||
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
|
||||
const MAX_PREVIEW_BYTES: usize = 8 << 20;
|
||||
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
|
||||
@@ -65,7 +73,7 @@ struct PackageWriter {
|
||||
|
||||
impl PackageWriter {
|
||||
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
if bytes.len() > if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART } {
|
||||
return Err(invalid("part too large"));
|
||||
}
|
||||
if !self.names.insert(name.to_owned()) {
|
||||
@@ -76,10 +84,21 @@ impl PackageWriter {
|
||||
return Err(invalid("package too large"));
|
||||
}
|
||||
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(1));
|
||||
let level = if name.ends_with(".step") { STEP_LEVEL } else { 1 };
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(level));
|
||||
self.zip.start_file(name, options).map_err(invalid)?;
|
||||
self.zip.write_all(bytes).map_err(invalid)
|
||||
}
|
||||
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep
|
||||
/// the file within `budget` and its expanded size within the reader's budget?
|
||||
fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
|
||||
// The bytes written so far, plus what the open compressed part may still hold back.
|
||||
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
|
||||
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
|
||||
self.names.len().saturating_add(entries) <= MAX_ENTRIES
|
||||
&& written.saturating_add(extra).saturating_add(directory) <= budget
|
||||
&& self.expanded.saturating_add(extra) <= MAX_EXPANDED
|
||||
}
|
||||
fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value> {
|
||||
self.resolved =
|
||||
self.resolved.checked_add(bytes.len()).filter(|&n| n <= MAX_EXPANDED).ok_or_else(|| invalid("resolved payloads too large"))?;
|
||||
@@ -89,9 +108,70 @@ impl PackageWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts what is written without keeping it.
|
||||
#[derive(Default)]
|
||||
struct Measure {
|
||||
position: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
impl Write for Measure {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.position = self.position.saturating_add(bytes.len() as u64);
|
||||
self.length = self.length.max(self.position);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for Measure {
|
||||
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
|
||||
let target = match position {
|
||||
SeekFrom::Start(n) => Some(n),
|
||||
SeekFrom::End(n) => self.length.checked_add_signed(n),
|
||||
SeekFrom::Current(n) => self.position.checked_add_signed(n),
|
||||
};
|
||||
self.position = target.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek out of range"))?;
|
||||
Ok(self.position)
|
||||
}
|
||||
}
|
||||
|
||||
/// Room kept in a design file for everything besides one large embedded STEP source.
|
||||
const STEP_RESERVE: usize = 4 << 20;
|
||||
|
||||
/// Can an import's STEP text be saved in a design? It must be no larger than an import
|
||||
/// accepts, and, compressed as a save stores it, leave room in a design file for the rest of
|
||||
/// the design. Checked when the file is imported, so it never imports and then fails to save.
|
||||
pub fn check_step_storable(step: &str) -> Result<()> {
|
||||
let limit = MAX_DESIGN_BYTES - STEP_RESERVE;
|
||||
if step.len() > MAX_STEP_PART {
|
||||
return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", step.len() >> 20, MAX_STEP_PART >> 20)));
|
||||
}
|
||||
// Deflate adds at most a few bytes per 16 KiB block: smaller text always fits.
|
||||
if step.len() <= limit - (limit >> 10) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut zip = zip::ZipWriter::new(Measure::default());
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(STEP_LEVEL));
|
||||
zip.start_file("blobs/source.step", options).map_err(invalid)?;
|
||||
zip.write_all(step.as_bytes()).map_err(invalid)?;
|
||||
let compressed = usize::try_from(zip.finish().map_err(invalid)?.length).unwrap_or(usize::MAX);
|
||||
if compressed > limit {
|
||||
return Err(IoError::Invalid(format!(
|
||||
"STEP file too large to save in a design: {} MB of STEP text compresses to {} MB, over the {} MB a design file may hold",
|
||||
step.len() >> 20,
|
||||
compressed >> 20,
|
||||
limit >> 20
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a compact container, deduplicating payloads by SHA-256. Mesh arrays are
|
||||
/// stored directly for low save/open latency; small metadata uses level-one
|
||||
/// Deflate. Use the explicit Compact option to compress STEP and meshes too.
|
||||
/// stored directly for low save/open latency; STEP sources and small metadata use
|
||||
/// fast Deflate. Use the explicit Compact option to compress meshes too.
|
||||
pub fn write_design_container(doc: &Document) -> Result<Vec<u8>> {
|
||||
write_design_container_with_compression(doc, DesignCompression::Fast)
|
||||
}
|
||||
@@ -145,7 +225,9 @@ fn write_container(
|
||||
for feature in &doc.features {
|
||||
let replacements = match &feature.kind {
|
||||
FeatureKind::Import { step, .. } => {
|
||||
let blob = writer.blob(step.as_bytes(), "step", compression == DesignCompression::Compact)?;
|
||||
// STEP is text and shrinks several times: always compressed, so an import as large
|
||||
// as MAX_STEP_BYTES still fits in a design file.
|
||||
let blob = writer.blob(step.as_bytes(), "step", true)?;
|
||||
fields([("step", Value::String(String::new())), ("step_blob", blob)])
|
||||
}
|
||||
FeatureKind::MeshImport { meshes, .. } => {
|
||||
@@ -190,14 +272,7 @@ fn write_container(
|
||||
// The model cache is optional: one that does not fit the container is left out, never a
|
||||
// failed save.
|
||||
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata).ok()).filter(|(manifest, data)| {
|
||||
let fits = writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
.expanded
|
||||
.saturating_add(manifest.len())
|
||||
.saturating_add(data.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
<= budget;
|
||||
let fits = writer.room(2, manifest.len().saturating_add(data.len()), budget);
|
||||
if !fits {
|
||||
log::info!("the model cache ({} MB) does not fit the design file: saved without it", (manifest.len() + data.len()) >> 20);
|
||||
}
|
||||
@@ -211,14 +286,7 @@ fn write_container(
|
||||
writer.part(payload_entry, &data, true)?;
|
||||
}
|
||||
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
|
||||
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
|
||||
&& writer
|
||||
.expanded
|
||||
.saturating_add(manifest.len())
|
||||
.saturating_add(data.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
<= budget
|
||||
&& writer.room(2, manifest.len().saturating_add(data.len()), budget)
|
||||
{
|
||||
writer.part("cache/preview.json", &manifest, false)?;
|
||||
writer.part("cache/preview.bin", &data, false)?;
|
||||
@@ -228,7 +296,7 @@ fn write_container(
|
||||
}
|
||||
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
|
||||
if bytes.len() > MAX_DESIGN_BYTES {
|
||||
return Err(invalid("compressed file too large"));
|
||||
return Err(invalid(format!("the design would be {} MB, over the {} MB a design file may hold", bytes.len() >> 20, MAX_DESIGN_BYTES >> 20)));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -345,7 +413,8 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
return Err(invalid("duplicate or invalid archive entry"));
|
||||
}
|
||||
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
|
||||
if part.size() > MAX_DESIGN_BYTES as u64 || total > MAX_EXPANDED as u64 {
|
||||
let limit = if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART };
|
||||
if part.size() > limit as u64 || total > MAX_EXPANDED as u64 {
|
||||
return Err(invalid("expanded package too large"));
|
||||
}
|
||||
}
|
||||
@@ -360,7 +429,7 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
|
||||
}
|
||||
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
|
||||
self.limited_part(name, MAX_DESIGN_BYTES)
|
||||
self.limited_part(name, if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART })
|
||||
}
|
||||
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
|
||||
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
|
||||
@@ -369,7 +438,9 @@ impl<R: Read + Seek> PackageReader<R> {
|
||||
return Err(invalid("part too large"));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(expected as usize);
|
||||
part.take(limit as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
// A compressed part may expand beyond its declared size (a decompression bomb): stop
|
||||
// one byte past it instead of at the limit.
|
||||
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
|
||||
if bytes.len() > limit || bytes.len() as u64 != expected {
|
||||
return Err(invalid("part too large or length mismatch"));
|
||||
}
|
||||
@@ -676,7 +747,7 @@ mod tests {
|
||||
#[test]
|
||||
fn seekable_preview_defers_large_source_blob_until_document_read() {
|
||||
let mut doc = Document::new("Deferred source");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
let step = noise(2_600_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
@@ -685,12 +756,12 @@ mod tests {
|
||||
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
|
||||
let mut reader = DesignContainerReader::new(source).unwrap();
|
||||
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
|
||||
// ZIP directory scans may touch a bounded suffix of a stored blob. They
|
||||
// ZIP directory scans may touch a bounded suffix of a source blob. They
|
||||
// must not load its multi-megabyte payload to obtain the saved picture.
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
|
||||
let restored = reader.read_document().unwrap();
|
||||
assert_eq!(restored.to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
assert!(count.get() >= step.len() / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -698,9 +769,11 @@ mod tests {
|
||||
let doc = document();
|
||||
let preview = DesignPreview { meshes: vec![mesh()] };
|
||||
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
let marker = b"ISO-10303-21;";
|
||||
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
|
||||
bytes[offset] ^= 1;
|
||||
// The first part written is the (compressed) STEP source: damage its data.
|
||||
let name = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
|
||||
let extra = u16::from_le_bytes([bytes[28], bytes[29]]) as usize;
|
||||
assert!(bytes[30..30 + name].ends_with(b".step"));
|
||||
bytes[30 + name + extra + 2] ^= 0x55;
|
||||
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
|
||||
assert!(reader.preview().is_some());
|
||||
assert!(reader.read_document().is_err());
|
||||
@@ -752,7 +825,7 @@ mod tests {
|
||||
#[test]
|
||||
fn native_cache_round_trip_and_preview_defer_original_sources() {
|
||||
let mut doc = Document::new("Native cache");
|
||||
let step = "ISO-10303-21;".repeat(200_000);
|
||||
let step = noise(2_600_000);
|
||||
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let bodies = native_geometry();
|
||||
@@ -767,7 +840,7 @@ mod tests {
|
||||
assert!(!reader.preview().unwrap().meshes.is_empty());
|
||||
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
|
||||
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
|
||||
assert!(count.get() >= step.len());
|
||||
assert!(count.get() >= step.len() / 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1159,7 +1232,7 @@ mod tests {
|
||||
assert!(crate::read_design(&duplicate).is_err());
|
||||
let mut bomb = bytes.clone();
|
||||
let central = bomb.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
|
||||
bomb[central + 24..central + 28].copy_from_slice(&((MAX_DESIGN_BYTES + 1) as u32).to_le_bytes());
|
||||
bomb[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
|
||||
assert!(crate::read_design(&bomb).unwrap_err().to_string().contains("expanded package too large"));
|
||||
let mut excessive = bytes;
|
||||
let end = excessive.windows(4).rposition(|v| v == b"PK\x05\x06").unwrap();
|
||||
@@ -1167,6 +1240,114 @@ mod tests {
|
||||
assert!(crate::read_design(&excessive).is_err());
|
||||
}
|
||||
|
||||
/// Printable text that compresses poorly (93 characters: about 6.5 bits each). No `*`, so
|
||||
/// it never ends a STEP comment.
|
||||
fn noise(length: usize) -> String {
|
||||
let mut state = 0x2545_f491_4f6c_dd1du64;
|
||||
(0..length)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
let c = b'!' + ((state >> 32) % 93) as u8;
|
||||
char::from(if c >= b'*' { c + 1 } else { c })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A valid STEP cube, padded with a comment to `length` bytes. `noisy` padding barely
|
||||
/// compresses; otherwise it is repetitive like the text of a real large export.
|
||||
fn padded_step(length: usize, noisy: bool) -> String {
|
||||
use solidcraft_geom::Vec3;
|
||||
let cube = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let step = solidcraft_kernel::step_export(&[&cube], "padded").unwrap();
|
||||
let (head, tail) = step.split_at(step.find("DATA;").unwrap() + 5);
|
||||
let end = length - tail.len() - 2;
|
||||
let mut out = String::with_capacity(length);
|
||||
out.push_str(head);
|
||||
out.push_str("\n/*");
|
||||
if noisy {
|
||||
out.push_str(&noise(end - out.len()));
|
||||
} else {
|
||||
let mut line = 0u64;
|
||||
while out.len() + 64 < end {
|
||||
line += 1;
|
||||
out.push_str(&format!("#{line}=CARTESIAN_POINT('',({}.,{}.5,-{}.25));\n", line % 997, line % 89, line % 13));
|
||||
}
|
||||
out.extend(std::iter::repeat_n(' ', end - out.len()));
|
||||
}
|
||||
out.push_str("*/");
|
||||
out.push_str(tail);
|
||||
assert_eq!(out.len(), length);
|
||||
out
|
||||
}
|
||||
|
||||
/// #3221: a STEP file larger than a design part used to import, then fail to save.
|
||||
#[test]
|
||||
fn step_over_the_part_limit_imports_saves_compressed_and_reopens() {
|
||||
let step = padded_step(MAX_DESIGN_BYTES + (1 << 20), false);
|
||||
let imported = crate::step_import_feature(step.as_bytes(), "large.step").unwrap();
|
||||
drop(step);
|
||||
let mut doc = Document::new("Large import");
|
||||
doc.add_feature(imported.kind, None).unwrap();
|
||||
let bytes = write_design_container(&doc).unwrap();
|
||||
assert!(bytes.len() < MAX_DESIGN_BYTES / 2, "saved {} MB", bytes.len() >> 20);
|
||||
let restored = crate::read_design(&bytes).unwrap();
|
||||
let (Some(FeatureKind::Import { step: before, .. }), Some(FeatureKind::Import { step: after, .. })) =
|
||||
(doc.features.first().map(|f| &f.kind), restored.features.first().map(|f| &f.kind))
|
||||
else {
|
||||
panic!("the reopened design lost its import");
|
||||
};
|
||||
assert!(before.len() > MAX_DESIGN_BYTES && before == after, "the reopened STEP text differs");
|
||||
let reopened = crate::step_import_feature(after.as_bytes(), "large.step").unwrap();
|
||||
assert_eq!(reopened.body_names, imported.body_names);
|
||||
assert_eq!(reopened.import.bodies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_that_cannot_be_saved_even_compressed_is_refused_at_import() {
|
||||
let step = padded_step(MAX_DESIGN_BYTES + (80 << 20), true);
|
||||
let message = crate::step_import_feature(step.as_bytes(), "noisy.step").err().unwrap().to_string();
|
||||
assert!(message.contains("too large to save in a design"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designs_with_uncompressed_step_sources_still_open() {
|
||||
// Saves before #3221 stored the STEP source without compression.
|
||||
let doc = document();
|
||||
let entries = parts(&write_design_container(&doc).unwrap());
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, bytes) in &entries {
|
||||
let method = if name.ends_with(".step") { zip::CompressionMethod::Stored } else { zip::CompressionMethod::Deflated };
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default().compression_method(method)).unwrap();
|
||||
zip.write_all(bytes).unwrap();
|
||||
}
|
||||
let old = zip.finish().unwrap().into_inner();
|
||||
assert!(old.windows(13).any(|v| v == b"ISO-10303-21;"));
|
||||
assert_eq!(crate::read_design(&old).unwrap(), doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_refused_without_expanding_it() {
|
||||
// 64 MiB that deflate to a few hundred kilobytes, declared as a 1 KiB STEP source.
|
||||
let mut doc = Document::new("Bomb");
|
||||
let step = format!("ISO-10303-21;{}", " ".repeat(64 << 20));
|
||||
doc.add_feature(FeatureKind::Import { file: "bomb.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None).unwrap();
|
||||
let honest = write_design_container(&doc).unwrap();
|
||||
assert!(honest.len() < 1 << 20);
|
||||
let central = honest.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
|
||||
let mut bomb = honest.clone();
|
||||
// The STEP source is the first part: its local header is at 0.
|
||||
bomb[22..26].copy_from_slice(&1024u32.to_le_bytes());
|
||||
bomb[central + 24..central + 28].copy_from_slice(&1024u32.to_le_bytes());
|
||||
let message = crate::read_design(&bomb).unwrap_err().to_string();
|
||||
assert!(message.contains("declared uncompressed size") || message.contains("length mismatch"), "{message}");
|
||||
// An honest declaration over the limit is refused before the part is read at all.
|
||||
let mut over = honest;
|
||||
over[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
|
||||
assert!(crate::read_design(&over).unwrap_err().to_string().contains("expanded package too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_references_and_repeated_payload_expansion() {
|
||||
let bytes = write_design_container(&document()).unwrap();
|
||||
|
||||
@@ -17,15 +17,7 @@ pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) ->
|
||||
}
|
||||
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
|
||||
.map_err(invalid)?;
|
||||
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|
||||
|| writer
|
||||
.expanded
|
||||
.saturating_add(key.len())
|
||||
.saturating_add(png.len())
|
||||
.saturating_add(writer.names.len().saturating_mul(256))
|
||||
.saturating_add(65_536)
|
||||
> MAX_DESIGN_BYTES
|
||||
{
|
||||
if !writer.room(2, key.len().saturating_add(png.len()), MAX_DESIGN_BYTES) {
|
||||
return Ok(());
|
||||
}
|
||||
writer.part("cache/thumbnail.json", &key, false)?;
|
||||
|
||||
@@ -542,8 +542,6 @@ mod tests {
|
||||
/// self-inserts fan out to 6^16 visits for no output. Both shapes must be refused quickly.
|
||||
#[test]
|
||||
fn empty_blocks_do_not_fan_out() {
|
||||
let t = std::time::Instant::now();
|
||||
|
||||
let fanout = format!(
|
||||
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n{}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
|
||||
"0\nINSERT\n2\nA\n".repeat(6)
|
||||
@@ -557,7 +555,5 @@ mod tests {
|
||||
);
|
||||
let g = read_dxf(&array).unwrap_or_default();
|
||||
assert!(g.is_empty(), "an empty block has no geometry to place");
|
||||
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "took {:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
+33
-12
@@ -101,8 +101,10 @@ pub fn is_iges_path(path: &str) -> bool {
|
||||
/// nothing), so the caller chooses.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum StepNames {
|
||||
/// The solid's own name in the file, else the name of the product that holds it.
|
||||
/// Keep meaningful solid names; replace placeholders with the nearest named product.
|
||||
#[default]
|
||||
Auto,
|
||||
/// The solid's own name in the file, else the name of the product that holds it.
|
||||
Solid,
|
||||
/// The name of the product (part) that holds the body, else the solid's name.
|
||||
Product,
|
||||
@@ -112,9 +114,10 @@ pub enum StepNames {
|
||||
}
|
||||
|
||||
impl StepNames {
|
||||
/// `solid`, `product` or `instance` (any case); `None` for anything else.
|
||||
/// `auto`, `solid`, `product` or `instance` (any case); `None` for anything else.
|
||||
pub fn parse(s: &str) -> Option<StepNames> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"auto" => Some(StepNames::Auto),
|
||||
"solid" => Some(StepNames::Solid),
|
||||
"product" => Some(StepNames::Product),
|
||||
"instance" => Some(StepNames::Instance),
|
||||
@@ -126,7 +129,16 @@ impl StepNames {
|
||||
/// A name that says nothing about its body: empty, or the placeholder a file or the importer
|
||||
/// gives to something it has no name for.
|
||||
fn generic_name(n: &str) -> bool {
|
||||
n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed")
|
||||
let n = n.trim();
|
||||
n.is_empty()
|
||||
|| n.starts_with('(')
|
||||
|| n.eq_ignore_ascii_case("import")
|
||||
|| n.eq_ignore_ascii_case("unnamed")
|
||||
|| ["body", "solid", "shell"].iter().any(|prefix| {
|
||||
n.get(..prefix.len()).is_some_and(|s| s.eq_ignore_ascii_case(prefix))
|
||||
&& n.get(prefix.len()..)
|
||||
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit() || c.is_ascii_whitespace() || matches!(c, '_' | '-' | '(' | ')')))
|
||||
})
|
||||
}
|
||||
|
||||
/// The name an imported body gets under the `names` choice; the solid's name when the chosen
|
||||
@@ -134,6 +146,8 @@ fn generic_name(n: &str) -> bool {
|
||||
fn body_name(b: &solidcraft_kernel::ImportedBody, names: StepNames) -> String {
|
||||
let usable = |n: &&String| !generic_name(n);
|
||||
let chosen = match names {
|
||||
StepNames::Auto if !generic_name(&b.name) => Some(&b.name),
|
||||
StepNames::Auto => b.path.iter().rev().find(usable).or_else(|| b.instance.as_ref().filter(usable)),
|
||||
StepNames::Solid => None,
|
||||
StepNames::Product => b.path.last().filter(usable),
|
||||
StepNames::Instance => b.instance.as_ref().filter(usable).or(b.path.last().filter(usable)),
|
||||
@@ -146,8 +160,7 @@ pub struct StepFeature {
|
||||
/// Timeline name: the root product's name, else the file name.
|
||||
pub name: String,
|
||||
pub kind: solidcraft_doc::FeatureKind,
|
||||
/// Body names from the file, as [`StepNames`] chose them (by default the solid names, else
|
||||
/// the product names).
|
||||
/// Body names from the file, as [`StepNames`] chose them (automatic by default).
|
||||
pub body_names: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
/// The file as read. Hold it until the feature has been evaluated: the timeline then reuses
|
||||
@@ -203,6 +216,8 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
|
||||
let text = step_text(bytes);
|
||||
if is_iges_path(file) { solidcraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) }
|
||||
};
|
||||
// The feature keeps this text and a save stores it: refuse now what could not be saved.
|
||||
design::check_step_storable(&text)?;
|
||||
let imp = solidcraft_kernel::step_import_shared(&text)?;
|
||||
let stem = std::path::Path::new(file)
|
||||
.file_stem()
|
||||
@@ -748,7 +763,7 @@ mod tests {
|
||||
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
|
||||
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
|
||||
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
|
||||
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
|
||||
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["t"], "still reads back");
|
||||
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
|
||||
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
|
||||
let model_xml = |bytes: Vec<u8>| {
|
||||
@@ -812,7 +827,7 @@ mod tests {
|
||||
let f = step_import_feature(s.as_bytes(), "/tmp/My Part.step").unwrap();
|
||||
// Named after the exported product (the export was called `t`).
|
||||
assert_eq!(f.name, "t");
|
||||
assert_eq!(f.body_names, ["Body1"]);
|
||||
assert_eq!(f.body_names, ["t"]);
|
||||
assert_eq!(f.body_names.len(), 1);
|
||||
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
|
||||
let mut imported = Document::new("Imported STEP round trip");
|
||||
@@ -862,9 +877,8 @@ mod tests {
|
||||
step_export_products(&products, 0, &StepHeader::default()).unwrap()
|
||||
}
|
||||
|
||||
/// The bodies of a STEP file are named after the solid (the default), the product that holds
|
||||
/// them, or the assembly occurrence; a name the file leaves out or fills with a placeholder
|
||||
/// falls back to the next one.
|
||||
/// Automatic names keep meaningful solid labels and replace placeholders with part names.
|
||||
/// Explicit choices still select the solid, product or occurrence.
|
||||
#[test]
|
||||
fn step_import_names_follow_the_choice() {
|
||||
let text = frame_step();
|
||||
@@ -872,10 +886,15 @@ mod tests {
|
||||
assert_eq!(names(&text, StepNames::Solid), ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1"]);
|
||||
assert_eq!(names(&text, StepNames::Product), ["Plate", "Post", "Post"]);
|
||||
assert_eq!(names(&text, StepNames::Instance), ["Plate-1", "Post-1", "Post-2"]);
|
||||
// Without a choice it is the solid's name, as before.
|
||||
// Meaningful solid names survive automatic naming, including SolidCraft exports.
|
||||
assert_eq!(step_import_feature(text.as_bytes(), "frame.step").unwrap().body_names, names(&text, StepNames::Solid));
|
||||
let generic = text.replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
|
||||
assert_eq!(step_import_feature(generic.as_bytes(), "frame.step").unwrap().body_names, ["Plate", "Post", "Post"]);
|
||||
assert_eq!(names(&generic, StepNames::Solid), ["Body1", "Body 2 (3)", "Body 2 (3)"]);
|
||||
let named = generic.replace("'Body1'", "'Body housing'");
|
||||
assert_eq!(names(&named, StepNames::Auto), ["Body housing", "Post", "Post"]);
|
||||
// The timeline entry is the root product's name whatever the bodies are called.
|
||||
for choice in [StepNames::Solid, StepNames::Product, StepNames::Instance] {
|
||||
for choice in [StepNames::Auto, StepNames::Solid, StepNames::Product, StepNames::Instance] {
|
||||
assert_eq!(step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().name, "Frame");
|
||||
}
|
||||
// A product the file leaves unnamed says nothing: its bodies keep the solid's name.
|
||||
@@ -887,6 +906,8 @@ mod tests {
|
||||
assert_eq!(names(&part, StepNames::Solid), ["Body1"]);
|
||||
assert_eq!(names(&part, StepNames::Product), ["Bracket"]);
|
||||
assert_eq!(names(&part, StepNames::Instance), ["Bracket"]);
|
||||
assert_eq!(names(&part, StepNames::Auto), ["Bracket"]);
|
||||
assert_eq!(StepNames::parse(" AuTo "), Some(StepNames::Auto));
|
||||
assert_eq!(StepNames::parse(" Product "), Some(StepNames::Product));
|
||||
assert_eq!(StepNames::parse("INSTANCE"), Some(StepNames::Instance));
|
||||
assert_eq!(StepNames::parse("label"), None);
|
||||
|
||||
@@ -467,6 +467,21 @@ outer
|
||||
survives("3MF (production extension)", &mutations(&production, false, 0x2089), usize::MAX, |b| {
|
||||
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
|
||||
});
|
||||
// Its model parts' XML mutated and zipped again: mutations of the zip mostly stop at its
|
||||
// checksums, these reach the `p:path` and `objectid` handling.
|
||||
let parts = crate::threemf::tests::production_parts();
|
||||
for (k, (name, xml)) in parts.iter().enumerate() {
|
||||
let rezipped: Vec<Vec<u8>> = mutations(xml.as_bytes(), true, 0x2089 + k as u64)
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, x)| (*n, if n == name { m.as_slice() } else { x.as_bytes() })).collect();
|
||||
crate::threemf::tests::package_bytes(&entries)
|
||||
})
|
||||
.collect();
|
||||
survives(&format!("3MF (production extension, {name} mutated)"), &rezipped, usize::MAX, |b| {
|
||||
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+395
-81
@@ -8,10 +8,12 @@
|
||||
//! precision so the receiver gets the tessellation exactly. Reading: every build item becomes one mesh
|
||||
//! (components flattened with their transforms), converted to millimetres. Components and build
|
||||
//! items may name an object in another model part of the package (`p:path`, the 3MF Production
|
||||
//! extension, which slicers use for multi-part projects); those parts are read too. Packages are
|
||||
//! untrusted: sizes, entry counts, model parts, mesh sizes and component nesting are capped.
|
||||
//! extension, which slicers use for multi-part projects); those parts are read too. Only the root
|
||||
//! model's build counts (readers must ignore the others), and a part that can't be read fails
|
||||
//! only the placements that need it. Packages are untrusted: sizes, entry counts, model parts,
|
||||
//! mesh sizes and component nesting are capped.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
@@ -352,10 +354,30 @@ fn bad<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
|
||||
}
|
||||
|
||||
/// The name a part is looked up by: no leading `/`, in lower case (part names compare
|
||||
/// case-insensitively).
|
||||
/// The name a part is looked up by: `%XX` escapes decoded, no leading `/`, in lower case (part
|
||||
/// names are URIs and compare case-insensitively).
|
||||
fn part_key(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
|
||||
let b = name.as_bytes();
|
||||
let hex = |k: usize| b.get(k).and_then(|h| char::from(*h).to_digit(16));
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
match (c, hex(i + 1), hex(i + 2)) {
|
||||
(b'%', Some(hi), Some(lo)) => {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
(b'\\', ..) => {
|
||||
out.push(b'/');
|
||||
i += 1;
|
||||
}
|
||||
_ => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).trim_start_matches('/').to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
|
||||
@@ -363,20 +385,22 @@ fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Opt
|
||||
let Some(idx) = (0..zip.len()).find(|i| zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| part_key(&n) == want)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
read_entry(zip, idx, name).map(Some)
|
||||
match read_entry(zip, idx, name, MAX_PART)? {
|
||||
Some(data) => Ok(Some(data)),
|
||||
None => bad(format!("{name} expands to more than 1 GB")),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str) -> Result<Vec<u8>> {
|
||||
/// Entry `idx` uncompressed, or `None` when it expands to more than `limit` bytes (zip bombs stop
|
||||
/// there).
|
||||
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str, limit: u64) -> Result<Option<Vec<u8>>> {
|
||||
let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
if f.size() > MAX_PART {
|
||||
return bad(format!("{name} expands to {} bytes", f.size()));
|
||||
if f.size() > limit {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.size().min(16 << 20) as usize);
|
||||
(&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
if out.len() as u64 > MAX_PART {
|
||||
return bad(format!("{name} expands to more than 1 GB"));
|
||||
}
|
||||
Ok(out)
|
||||
(&mut f).take(limit.saturating_add(1)).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
|
||||
Ok((out.len() as u64 <= limit).then_some(out))
|
||||
}
|
||||
|
||||
fn attrs(e: &BytesStart) -> HashMap<String, String> {
|
||||
@@ -435,10 +459,16 @@ fn det(m: &Xf) -> f64 {
|
||||
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
|
||||
}
|
||||
|
||||
/// A component or build item: object `id` placed by `xf`, in model part `part` (its key, from
|
||||
/// `p:path`) or, without one, in the part that names it.
|
||||
/// A model part named by `p:path`: its key ([`part_key`]) and the name as written, for messages.
|
||||
struct PartName {
|
||||
key: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// A component or build item: object `id` placed by `xf`, in model part `part` or, without one,
|
||||
/// in the part that names it.
|
||||
struct Ref {
|
||||
part: Option<String>,
|
||||
part: Option<PartName>,
|
||||
id: u32,
|
||||
xf: Xf,
|
||||
}
|
||||
@@ -453,19 +483,29 @@ struct Object {
|
||||
}
|
||||
|
||||
/// One model part. Positions and translations are in millimetres (converted with the part's
|
||||
/// unit as it is read), so objects of parts in different units fit together.
|
||||
/// unit as it is read), so objects of parts in different units fit together. Objects are kept in
|
||||
/// id order, so the parts they name are read, and reported, in the same order every run.
|
||||
#[derive(Default)]
|
||||
struct Model {
|
||||
unit: f64,
|
||||
objects: HashMap<u32, Object>,
|
||||
objects: BTreeMap<u32, Object>,
|
||||
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
|
||||
build: Vec<Ref>,
|
||||
}
|
||||
|
||||
/// The model parts read from a package, by key, and the key of the root model.
|
||||
/// The model parts of a package by key: the root model's key, the parts read, the parts that are
|
||||
/// in the package but could not be read (with the error), and each part's name as first written.
|
||||
struct Package {
|
||||
root: String,
|
||||
parts: HashMap<String, Model>,
|
||||
failed: HashMap<String, String>,
|
||||
names: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
/// Part `key` as the package names it.
|
||||
fn shown<'a>(&'a self, key: &'a str) -> &'a str {
|
||||
self.names.get(key).map_or(key, String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
fn color(s: &str) -> Option<[f32; 3]> {
|
||||
@@ -489,11 +529,19 @@ fn unit_mm(u: &str) -> Option<f64> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
/// The model part's name in `p:path`, if any.
|
||||
fn part_name(a: &HashMap<String, String>) -> Option<PartName> {
|
||||
a.get("path").map(|p| PartName { key: part_key(p), path: p.clone() })
|
||||
}
|
||||
|
||||
/// Model part `xml`. Only the `root` model's build items are read: the spec has readers ignore
|
||||
/// the build sections of the other parts (slicers leave them empty or point them anywhere).
|
||||
fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
|
||||
let mut rd = Reader::from_reader(xml);
|
||||
rd.config_mut().trim_text(true);
|
||||
let mut buf = Vec::new();
|
||||
let mut m = Model { unit: 1.0, ..Default::default() };
|
||||
let mut m = Model::default();
|
||||
let mut unit = 1.0;
|
||||
let mut depth = 0usize;
|
||||
let mut cur: Option<(u32, Object)> = None;
|
||||
let mut cur_mat: Option<u32> = None;
|
||||
@@ -541,7 +589,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
b"model" => {
|
||||
seen_model = true;
|
||||
if let Some(u) = a.get("unit") {
|
||||
m.unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
|
||||
unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
|
||||
}
|
||||
}
|
||||
b"basematerials" => {
|
||||
@@ -573,7 +621,7 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
return bad("too many vertices");
|
||||
}
|
||||
let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") };
|
||||
o.positions.push(Vec3::new(x, y, z) * m.unit);
|
||||
o.positions.push(Vec3::new(x, y, z) * unit);
|
||||
}
|
||||
b"triangle" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
|
||||
@@ -586,11 +634,13 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
b"component" => {
|
||||
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
|
||||
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?;
|
||||
o.components.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
|
||||
// The spec allows `p:path` only in the root model's components; a reader can still
|
||||
// follow it from any part (loops and depth are checked when placing).
|
||||
o.components.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
|
||||
}
|
||||
b"item" => {
|
||||
b"item" if root => {
|
||||
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?;
|
||||
m.build.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
|
||||
m.build.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -602,29 +652,30 @@ fn parse_model(xml: &[u8]) -> Result<Model> {
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Queue the model parts `m` names that were not seen yet.
|
||||
fn queue_parts(m: &Model, seen: &mut HashSet<String>, pending: &mut Vec<String>) -> Result<()> {
|
||||
for key in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
|
||||
if !seen.contains(key) {
|
||||
/// Queue the model parts `m` names that were not seen yet, noting how each is spelled (`m.build`
|
||||
/// is empty but for the root).
|
||||
fn queue_parts(m: &Model, names: &mut HashMap<String, String>, pending: &mut Vec<String>) -> Result<()> {
|
||||
for p in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
|
||||
if !names.contains_key(&p.key) {
|
||||
// A package holds no more parts than it has entries.
|
||||
if seen.len() >= MAX_ENTRIES {
|
||||
if names.len() >= MAX_ENTRIES {
|
||||
return bad(format!("objects name more than {MAX_ENTRIES} model parts"));
|
||||
}
|
||||
seen.insert(key.clone());
|
||||
pending.push(key.clone());
|
||||
names.insert(p.key.clone(), p.path.clone());
|
||||
pending.push(p.key.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An error reading model part `key`, saying which part it was.
|
||||
fn in_part(key: &str, e: IoError) -> IoError {
|
||||
/// An error reading model part `name`, saying which part it was.
|
||||
fn in_part(name: &str, e: IoError) -> String {
|
||||
match e {
|
||||
IoError::Invalid(msg) => {
|
||||
let msg = msg.strip_prefix("3MF: ").or_else(|| msg.strip_prefix("3MF ")).unwrap_or(&msg);
|
||||
IoError::Invalid(format!("3MF model part `{key}`: {msg}"))
|
||||
format!("3MF model part `{name}`: {msg}")
|
||||
}
|
||||
e => e,
|
||||
e => e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,15 +689,22 @@ fn flatten<'a>(
|
||||
stack: &mut Vec<(&'a str, u32)>,
|
||||
total: &mut usize,
|
||||
) -> Result<()> {
|
||||
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&(part, id)) {
|
||||
return bad("components nested too deeply or recursive");
|
||||
// Loops are keyed by (part, object): parts number their objects on their own, so object 1 of
|
||||
// the root placing object 1 of another part is no loop.
|
||||
if stack.contains(&(part, id)) {
|
||||
return bad("components place each other in a loop");
|
||||
}
|
||||
let m = pkg.parts.get(part).ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{part}`")))?;
|
||||
if stack.len() > MAX_COMPONENT_DEPTH {
|
||||
return bad(format!("components nested more than {MAX_COMPONENT_DEPTH} deep"));
|
||||
}
|
||||
let Some(m) = pkg.parts.get(part) else {
|
||||
return Err(IoError::Invalid(pkg.failed.get(part).cloned().unwrap_or_else(|| format!("3MF: no model part `{}`", pkg.shown(part)))));
|
||||
};
|
||||
let o = m.objects.get(&id).ok_or_else(|| {
|
||||
IoError::Invalid(if part == pkg.root {
|
||||
format!("3MF: missing object {id}")
|
||||
} else {
|
||||
format!("3MF: missing object {id} in model part `{part}`")
|
||||
format!("3MF: missing object {id} in model part `{}`", pkg.shown(part))
|
||||
})
|
||||
})?;
|
||||
// Every placement costs at least one unit of the budget, so objects that only place other
|
||||
@@ -695,7 +753,8 @@ fn flatten<'a>(
|
||||
}
|
||||
stack.push((part, id));
|
||||
for c in &o.components {
|
||||
flatten(pkg, c.part.as_deref().unwrap_or(part), c.id, &compose(&c.xf, xf), out, stack, total)?;
|
||||
// Without `p:path` a component is in the part that names it, not in the root.
|
||||
flatten(pkg, c.part.as_ref().map_or(part, |p| p.key.as_str()), c.id, &compose(&c.xf, xf), out, stack, total)?;
|
||||
}
|
||||
stack.pop();
|
||||
Ok(())
|
||||
@@ -733,40 +792,56 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
|
||||
}
|
||||
}
|
||||
let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?;
|
||||
let root = part_key(&root);
|
||||
let model = parse_model(&xml)?;
|
||||
let mut model_bytes = xml.len();
|
||||
let root_name = root;
|
||||
let root = part_key(&root_name);
|
||||
let model = parse_model(&xml, true)?;
|
||||
// All model parts together may expand to 1 GB; only one is held as XML at a time.
|
||||
let mut model_bytes = xml.len() as u64;
|
||||
drop(xml);
|
||||
// The model parts objects name (production extension), each read once. The entries are
|
||||
// indexed first, so a package naming thousands of parts costs no scan per name; a name
|
||||
// with no entry is left out, and the placements that need it say so.
|
||||
// indexed first, so a package naming thousands of parts costs no scan per name. A name with
|
||||
// no entry is left out and a part that can't be read is noted: either way only the
|
||||
// placements that need it fail, and say why.
|
||||
let mut entries = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
if let Some(Ok(n)) = zip.name_for_index(i) {
|
||||
entries.entry(part_key(&n)).or_insert(i);
|
||||
}
|
||||
}
|
||||
let mut seen = HashSet::from([root.clone()]);
|
||||
let mut names = HashMap::from([(root.clone(), root_name)]);
|
||||
let mut pending = Vec::new();
|
||||
queue_parts(&model, &mut seen, &mut pending)?;
|
||||
queue_parts(&model, &mut names, &mut pending)?;
|
||||
let mut parts = HashMap::from([(root.clone(), model)]);
|
||||
let mut failed = HashMap::new();
|
||||
while let Some(key) = pending.pop() {
|
||||
let Some(&idx) = entries.get(&key) else { continue };
|
||||
let xml = read_entry(&mut zip, idx, &key)?;
|
||||
model_bytes = model_bytes.saturating_add(xml.len());
|
||||
if model_bytes as u64 > MAX_PART {
|
||||
return bad("model parts expand to more than 1 GB");
|
||||
let name = names.get(&key).cloned().unwrap_or_else(|| key.clone());
|
||||
let read = match read_entry(&mut zip, idx, &name, MAX_PART.saturating_sub(model_bytes)) {
|
||||
Ok(Some(xml)) => {
|
||||
model_bytes = model_bytes.saturating_add(xml.len() as u64);
|
||||
parse_model(&xml, false)
|
||||
}
|
||||
// The package-wide caps refuse the whole package, not just this part.
|
||||
Ok(None) => return bad("model parts expand to more than 1 GB"),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match read {
|
||||
Ok(m) => {
|
||||
queue_parts(&m, &mut names, &mut pending)?;
|
||||
parts.insert(key, m);
|
||||
}
|
||||
Err(e) => {
|
||||
failed.insert(key, in_part(&name, e));
|
||||
}
|
||||
}
|
||||
let m = parse_model(&xml).map_err(|e| in_part(&key, e))?;
|
||||
queue_parts(&m, &mut seen, &mut pending)?;
|
||||
parts.insert(key, m);
|
||||
}
|
||||
let pkg = Package { root, parts };
|
||||
let pkg = Package { root, parts, failed, names };
|
||||
let Some(model) = pkg.parts.get(&pkg.root) else { return bad("no root model") };
|
||||
let mut out = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for (k, item) in model.build.iter().enumerate() {
|
||||
let part = item.part.as_deref().unwrap_or(&pkg.root);
|
||||
let part = item.part.as_ref().map_or(pkg.root.as_str(), |p| p.key.as_str());
|
||||
let mut mo = MeshObject::default();
|
||||
match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) {
|
||||
Ok(()) if !mo.triangles.is_empty() => {
|
||||
@@ -814,16 +889,21 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
/// A package of these parts (name, contents).
|
||||
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
pub(crate) fn package_bytes(parts: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for (name, data) in parts {
|
||||
zip.start_file(*name, o).unwrap();
|
||||
zip.write_all(data.as_bytes()).unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A package of these text parts (name, contents).
|
||||
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
package_bytes(&parts.iter().map(|(n, d)| (*n, d.as_bytes())).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
|
||||
let mut parts: Vec<(&str, &str)> = rels.map(|r| ("_rels/.rels", r)).into_iter().collect();
|
||||
parts.push(("3D/3dmodel.model", model));
|
||||
@@ -850,9 +930,9 @@ pub(crate) mod tests {
|
||||
o.triangles.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2])))).sum::<f64>() / 6.0
|
||||
}
|
||||
|
||||
/// A multi-part project as slicers save it: the root model's object is two components in
|
||||
/// another model part, which holds the meshes.
|
||||
pub(crate) fn production_package() -> Vec<u8> {
|
||||
/// The model parts (name, XML) of a multi-part project as slicers save it: the root model's
|
||||
/// object is two components in another model part, which holds the meshes.
|
||||
pub(crate) fn production_parts() -> [(&'static str, String); 2] {
|
||||
let root = format!(
|
||||
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources><object id="10" type="model" name="Pair"><components>
|
||||
<component p:path="/3D/Objects/object_1.model" objectid="1"/>
|
||||
@@ -860,7 +940,12 @@ pub(crate) mod tests {
|
||||
</components></object></resources><build><item objectid="10" transform="1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#
|
||||
);
|
||||
let objects = model_part("millimeter", &format!("{}{}", tet_object(1, "A", 1.0), tet_object(2, "B", 2.0)));
|
||||
package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])
|
||||
[("3D/3dmodel.model", root), (OBJECTS_PART, objects)]
|
||||
}
|
||||
|
||||
pub(crate) fn production_package() -> Vec<u8> {
|
||||
let [(r, root), (o, objects)] = production_parts();
|
||||
package_parts(&[(r, &root), (o, &objects)])
|
||||
}
|
||||
|
||||
/// Objects kept in a model part of their own (3MF Production extension) come in with their
|
||||
@@ -916,22 +1001,40 @@ pub(crate) mod tests {
|
||||
r#"<object id="1" type="model"><components><component p:path="/3D/3dmodel.model" objectid="10"/></components></object>"#,
|
||||
);
|
||||
let many: String = (0..=MAX_ENTRIES).map(|i| format!(r#"<component p:path="/p{i}.model" objectid="1"/>"#)).collect();
|
||||
// Each with the message it must give: before the production extension was read, every
|
||||
// one of these failed alike, on the first `p:path`.
|
||||
let cases = [
|
||||
// A part that isn't in the package, one outside it and an empty name.
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/missing.model" objectid="1"/>"#))]),
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
// A part that isn't in the package (named as written), one outside it and an empty name.
|
||||
(
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/Missing.model" objectid="1"/>"#))]),
|
||||
"no model part `/3D/Objects/Missing.model`",
|
||||
),
|
||||
(
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
"no model part `/../../etc/passwd`",
|
||||
),
|
||||
(package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]), "no model part ``"),
|
||||
// An object the part doesn't have.
|
||||
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)), (OBJECTS_PART, &tet)]),
|
||||
(
|
||||
package_parts(&[
|
||||
("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)),
|
||||
(OBJECTS_PART, &tet),
|
||||
]),
|
||||
"missing object 7 in model part `/3D/Objects/object_1.model`",
|
||||
),
|
||||
// A part that is no model.
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
|
||||
(
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
|
||||
"model part `/3D/Objects/object_1.model`: no <model> element",
|
||||
),
|
||||
// Parts that place each other in a loop.
|
||||
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]),
|
||||
(package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]), "components place each other in a loop"),
|
||||
// More part names than a package can hold.
|
||||
package_parts(&[("3D/3dmodel.model", &root(&many))]),
|
||||
(package_parts(&[("3D/3dmodel.model", &root(&many))]), "objects name more than 10000 model parts"),
|
||||
];
|
||||
for (i, c) in cases.iter().enumerate() {
|
||||
assert!(read_3mf(c).is_err(), "case {i}");
|
||||
for (i, (c, msg)) in cases.iter().enumerate() {
|
||||
let err = read_3mf(c).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains(msg), "case {i}: {err}");
|
||||
}
|
||||
// A placement whose part is missing is reported; the others still come in.
|
||||
let two = format!(
|
||||
@@ -942,6 +1045,221 @@ pub(crate) mod tests {
|
||||
assert!(warnings.iter().any(|w| w.contains("build item 2") && w.contains("gone.model")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// The root model: `objects`, and a build of `items`.
|
||||
fn root_model(objects: &str, items: &str) -> String {
|
||||
format!(r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build>{items}</build></model>"#)
|
||||
}
|
||||
|
||||
/// Object `id` made of components: (`p:path`, or "" for none, and object id).
|
||||
fn assembly(id: u32, components: &[(&str, u32)]) -> String {
|
||||
let c: String = components
|
||||
.iter()
|
||||
.map(|(path, oid)| match *path {
|
||||
"" => format!(r#"<component objectid="{oid}"/>"#),
|
||||
path => format!(r#"<component p:path="{path}" objectid="{oid}"/>"#),
|
||||
})
|
||||
.collect();
|
||||
format!(r#"<object id="{id}" type="model"><components>{c}</components></object>"#)
|
||||
}
|
||||
|
||||
/// A component without `p:path` in another part is in that part, not in the root, which has
|
||||
/// an object of the same id too (T1 of the review of #2127).
|
||||
#[test]
|
||||
fn components_without_a_path_stay_in_their_part() {
|
||||
let root =
|
||||
root_model(&format!("{}{}", tet_object(2, "Root", 5.0), assembly(10, &[("/3D/Objects/object_1.model", 1)])), r#"<item objectid="10"/>"#);
|
||||
let objects = model_part("millimeter", &format!("{}{}", assembly(1, &[("", 2)]), tet_object(2, "Child", 2.0)));
|
||||
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert!((volume(&objs[0]) - 8.0 / 6.0).abs() < 1e-9, "the child's object 2, not the root's: {}", volume(&objs[0]));
|
||||
}
|
||||
|
||||
/// Parts number their objects on their own, so root object 1 placing object 1 of another part
|
||||
/// is no loop (T2).
|
||||
#[test]
|
||||
fn the_same_id_in_two_parts_is_no_loop() {
|
||||
let root = root_model(&assembly(1, &[("/3D/Objects/object_1.model", 1)]), r#"<item objectid="1"/>"#);
|
||||
let objects = model_part("millimeter", &tet_object(1, "A", 1.0));
|
||||
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(objs.len() == 1 && (volume(&objs[0]) - 1.0 / 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A package shaped like a slicer project (Bambu Studio, OrcaSlicer): object UUIDs, printable
|
||||
/// build items, thumbnails, slicer configs, and the meshes in a part of their own with an empty
|
||||
/// build (T3). Each object on the plate is one body with all its volumes. The slicer's config
|
||||
/// (names, which volumes are modifiers) isn't read, so the bodies are named `MeshN` and the
|
||||
/// modifier comes in as solid.
|
||||
#[test]
|
||||
fn slicer_project_package() {
|
||||
let ns = r#"xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p""#;
|
||||
let uuid = |n: u32| format!("{n:08x}-61cb-4c03-9d28-80fed5dfa1dc");
|
||||
let component = |oid: u32| {
|
||||
format!(
|
||||
r#"<component p:path="/3D/Objects/object_1.model" objectid="{oid}" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>"#,
|
||||
uuid(100 + oid)
|
||||
)
|
||||
};
|
||||
let root = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" xml:lang="en-US" {ns}>
|
||||
<metadata name="Application">BambuStudio-02.03.00.70</metadata>
|
||||
<metadata name="BambuStudio:3mfVersion">1</metadata>
|
||||
<resources>
|
||||
<object id="2" p:UUID="{}" type="model"><components>{}</components></object>
|
||||
<object id="4" p:UUID="{}" type="model"><components>{}{}</components></object>
|
||||
</resources>
|
||||
<build p:UUID="{}">
|
||||
<item objectid="2" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 128 128 0" printable="1"/>
|
||||
<item objectid="4" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 90 128 0" printable="1"/>
|
||||
</build>
|
||||
</model>"#,
|
||||
uuid(2),
|
||||
component(1),
|
||||
uuid(4),
|
||||
component(3),
|
||||
component(5),
|
||||
uuid(200),
|
||||
uuid(201),
|
||||
uuid(202)
|
||||
);
|
||||
let mesh = |id: u32, s: f64| tet_object(id, "", s).replace(r#" name="""#, &format!(r#" p:UUID="{}""#, uuid(300 + id)));
|
||||
let objects = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<model unit="millimeter" xml:lang="en-US" {ns}>
|
||||
<metadata name="BambuStudio:3mfVersion">1</metadata>
|
||||
<resources>{}{}{}</resources>
|
||||
<build/>
|
||||
</model>"#,
|
||||
mesh(1, 10.0),
|
||||
mesh(3, 20.0),
|
||||
mesh(5, 4.0)
|
||||
);
|
||||
let settings = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config>
|
||||
<object id="2"><metadata key="name" value="Cube"/><part id="1" subtype="normal_part"><metadata key="name" value="Cube"/></part></object>
|
||||
<object id="4"><metadata key="name" value="Bracket"/><part id="3" subtype="normal_part"/><part id="5" subtype="modifier_part"/></object>
|
||||
<plate><metadata key="plater_id" value="1"/><metadata key="thumbnail_file" value="Metadata/plate_1.png"/></plate>
|
||||
</config>"#;
|
||||
let content_types = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
|
||||
let rels = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="{MODEL_REL}"/><Relationship Target="/Metadata/plate_1.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/></Relationships>"#
|
||||
);
|
||||
let model_rels = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/Objects/object_1.model" Id="rel-1" Type="{MODEL_REL}"/></Relationships>"#
|
||||
);
|
||||
let png: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
|
||||
let package = package_bytes(&[
|
||||
("[Content_Types].xml", content_types.as_bytes()),
|
||||
("_rels/.rels", rels.as_bytes()),
|
||||
("3D/3dmodel.model", root.as_bytes()),
|
||||
("3D/_rels/3dmodel.model.rels", model_rels.as_bytes()),
|
||||
(OBJECTS_PART, objects.as_bytes()),
|
||||
("Metadata/model_settings.config", settings.as_bytes()),
|
||||
("Metadata/project_settings.config", br#"{"layer_height": "0.2"}"#),
|
||||
("Metadata/plate_1.png", png),
|
||||
("Metadata/plate_1_small.png", png),
|
||||
]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let names: Vec<&str> = objs.iter().map(|o| o.name.as_str()).collect();
|
||||
assert_eq!(names, ["Mesh1", "Mesh2"]);
|
||||
assert_eq!((objs[0].triangles.len(), objs[1].triangles.len()), (4, 8));
|
||||
assert!((volume(&objs[0]) - 1000.0 / 6.0).abs() < 1e-6, "{}", volume(&objs[0]));
|
||||
assert!((volume(&objs[1]) - (8000.0 + 64.0) / 6.0).abs() < 1e-6, "{}", volume(&objs[1]));
|
||||
assert!(objs[0].positions.iter().all(|p| p.x >= 128.0 - 1e-9 && p.y >= 128.0 - 1e-9));
|
||||
}
|
||||
|
||||
/// Placements in every part draw on one budget (T4): three parts fanning out 300 ways each
|
||||
/// (27 million placements of an empty object) stop at it, and only the build item that went
|
||||
/// over fails.
|
||||
#[test]
|
||||
fn parts_share_the_placement_budget() {
|
||||
let t = std::time::Instant::now();
|
||||
let fan = |id: u32, path: &str| assembly(id, &vec![(path, 1); 300]);
|
||||
let root = root_model(&fan(10, "/3D/Objects/a.model"), r#"<item p:path="/3D/Objects/a.model" objectid="9"/><item objectid="10"/>"#);
|
||||
let a = model_part("millimeter", &format!("{}{}", fan(1, "/3D/Objects/b.model"), tet_object(9, "Tet", 1.0)));
|
||||
let b = model_part("millimeter", &fan(1, "/3D/Objects/c.model"));
|
||||
let c = model_part("millimeter", r#"<object id="1" type="model"/>"#);
|
||||
let package =
|
||||
package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/a.model", &a), ("3D/Objects/b.model", &b), ("3D/Objects/c.model", &c)]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert!(warnings.len() == 1 && warnings[0].starts_with("build item 2") && warnings[0].contains("placements"), "{warnings:?}");
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// A loop through three parts with different object ids is found, and a chain of parts deeper
|
||||
/// than the cap stops there while one level less still comes in (T5).
|
||||
#[test]
|
||||
fn loops_and_deep_chains_across_parts() {
|
||||
let read = |parts: &[(String, String)]| read_3mf(&package_parts(&parts.iter().map(|(n, x)| (n.as_str(), x.as_str())).collect::<Vec<_>>()));
|
||||
let part = |name: &str, objects: String| (name.to_string(), model_part("millimeter", &objects));
|
||||
let looped = [
|
||||
("3D/3dmodel.model".to_string(), root_model("", r#"<item p:path="/3D/Objects/a.model" objectid="1"/>"#)),
|
||||
part("3D/Objects/a.model", assembly(1, &[("/3D/Objects/b.model", 2)])),
|
||||
part("3D/Objects/b.model", assembly(2, &[("/3D/Objects/c.model", 3)])),
|
||||
part("3D/Objects/c.model", assembly(3, &[("/3D/Objects/a.model", 1)])),
|
||||
];
|
||||
let err = read(&looped).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains("components place each other in a loop"), "{err}");
|
||||
// Object k of part k places object k + 1 of part k + 1; the last one is a mesh.
|
||||
let chain = |n: u32| {
|
||||
let name = |k: u32| format!("3D/Objects/c{k}.model");
|
||||
let mut parts = vec![("3D/3dmodel.model".to_string(), root_model("", &format!(r#"<item p:path="/{}" objectid="1"/>"#, name(1))))];
|
||||
for k in 1..=n {
|
||||
let object = if k == n { tet_object(k, "End", 1.0) } else { assembly(k, &[(format!("/{}", name(k + 1)).as_str(), k + 1)]) };
|
||||
parts.push(part(&name(k), object));
|
||||
}
|
||||
read(&parts)
|
||||
};
|
||||
let deepest = MAX_COMPONENT_DEPTH as u32 + 1;
|
||||
let (objs, warnings) = chain(deepest).unwrap();
|
||||
assert!(objs.len() == 1 && warnings.is_empty(), "{warnings:?}");
|
||||
let err = chain(deepest + 1).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("components nested more than {MAX_COMPONENT_DEPTH} deep")), "{err}");
|
||||
}
|
||||
|
||||
/// A part that can't be read fails only the placements that need it, and none when only
|
||||
/// objects nobody places name it. The build sections of other parts are ignored, even ones
|
||||
/// pointing at a thumbnail or missing their object id (T6).
|
||||
#[test]
|
||||
fn broken_parts_and_the_builds_of_other_parts() {
|
||||
let root = root_model(
|
||||
&assembly(20, &[("/3D/Objects/unused.model", 1)]),
|
||||
r#"<item p:path="/3D/Objects/object_1.model" objectid="1"/><item p:path="/3D/Objects/Broken.model" objectid="1"/>"#,
|
||||
);
|
||||
let objects = format!(
|
||||
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{}</resources><build><item p:path="/Metadata/plate_1.png" objectid="1"/><item/></build></model>"#,
|
||||
tet_object(1, "A", 1.0)
|
||||
);
|
||||
let package = package_parts(&[
|
||||
("3D/3dmodel.model", &root),
|
||||
(OBJECTS_PART, &objects),
|
||||
("3D/Objects/broken.model", "not xml"),
|
||||
("3D/Objects/unused.model", "<model><resources><object id="),
|
||||
("Metadata/plate_1.png", "\u{89}PNG"),
|
||||
]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert_eq!(objs.len(), 1);
|
||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
||||
assert!(
|
||||
warnings[0].starts_with("build item 2") && warnings[0].contains("model part `/3D/Objects/Broken.model`: no <model> element"),
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Part names are URIs: a `%XX` escape matches the character it stands for, either way round.
|
||||
#[test]
|
||||
fn escaped_part_names() {
|
||||
let root = root_model("", r#"<item p:path="/3D/Objects/my%20part.model" objectid="1"/><item p:path="/3D/Objects/café.model" objectid="1"/>"#);
|
||||
let tet = model_part("millimeter", &tet_object(1, "A", 1.0));
|
||||
let package = package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/my part.model", &tet), ("3D/Objects/caf%C3%A9.model", &tet)]);
|
||||
let (objs, warnings) = read_3mf(&package).unwrap();
|
||||
assert!(warnings.is_empty() && objs.len() == 2, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_is_well_formed() {
|
||||
let mut t = tetra("A <&> \"b\"");
|
||||
@@ -1034,7 +1352,6 @@ pub(crate) mod tests {
|
||||
/// allocation, which no panic guard can intercept.
|
||||
#[test]
|
||||
fn many_placements_of_a_big_object_stay_bounded() {
|
||||
let t = std::time::Instant::now();
|
||||
let verts: String = (0..100_000).map(|i| format!("<vertex x=\"{}\" y=\"{}\" z=\"0\"/>", i % 100, i / 100)).collect();
|
||||
let model = format!(
|
||||
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices>\
|
||||
@@ -1046,7 +1363,6 @@ pub(crate) mod tests {
|
||||
assert_eq!(objs.len(), 10_000);
|
||||
// Only the three vertices the one triangle reaches are copied per placement.
|
||||
assert!(objs.iter().all(|o| o.positions.len() == 3 && o.triangles.len() == 1), "{:?}", objs.first().map(|o| o.positions.len()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Objects that only place other objects count toward the budget too: six levels of 300
|
||||
@@ -1054,7 +1370,6 @@ pub(crate) mod tests {
|
||||
/// budget instead of running for days.
|
||||
#[test]
|
||||
fn nested_fan_out_of_empty_objects_is_bounded() {
|
||||
let t = std::time::Instant::now();
|
||||
let components = |child: u32| (0..300).map(|_| format!(r#"<component objectid="{child}"/>"#)).collect::<String>();
|
||||
let mut objects = r#"<object id="1" type="model"/>"#.to_string();
|
||||
for id in 2..=7 {
|
||||
@@ -1063,7 +1378,6 @@ pub(crate) mod tests {
|
||||
let model = format!(r#"<model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build><item objectid="7"/></build></model>"#);
|
||||
let err = read_3mf(&package(&model, None)).unwrap_err().to_string();
|
||||
assert!(err.contains("placements"), "{err}");
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
|
||||
}
|
||||
|
||||
/// Triangles are budgeted across every placement, so fan-out cannot outrun the cap.
|
||||
|
||||
+81
-17
@@ -23,6 +23,16 @@ pub struct Body {
|
||||
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
|
||||
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
|
||||
pub(crate) size: f64,
|
||||
/// How far this body's face boundaries may lie off their surfaces (mm) and still be taken as
|
||||
/// lying on them. `Some` for a body read from a file that declares its own numeric
|
||||
/// uncertainty (a STEP file's `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`, see
|
||||
/// `crate::step_in::Units::tol`); `None` for a body this kernel built, which has no such
|
||||
/// declaration.
|
||||
///
|
||||
/// A band face's ring edges are held to it when the importer splits the band (see
|
||||
/// `crate::step_in::split`). The display planner takes it only where it is more than its own
|
||||
/// bound, a hundredth of the body (see `accept` in [`crate::tess::plan_face`]).
|
||||
pub(crate) boundary_tol: Option<f64>,
|
||||
/// The last volume measured for this solid and the tolerance it was measured at, shared by
|
||||
/// clones because they share the solid. A boolean measures its operands, and a later boolean
|
||||
/// takes the result as an operand again, so the same body is measured twice otherwise.
|
||||
@@ -541,6 +551,17 @@ thread_local! {
|
||||
pub(crate) static REPHASED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The volume one closed shell encloses, signed by the way its faces face (positive when they
|
||||
/// face out of it), from a mesh as coarse as the orientation mesh of a solid of its size.
|
||||
/// `None` when it cannot be meshed.
|
||||
pub(crate) fn shell_volume(shell: &mt::Shell) -> Option<f64> {
|
||||
let bounds: Aabb3 = shell.vertex_iter().map(|v| from_p3(v.point())).collect();
|
||||
let span = if bounds.is_empty() { 1.0 } else { bounds.diagonal() };
|
||||
let deflection = Deflection { display: true, ..Deflection::chord(orientation_tolerance(span)) };
|
||||
let v = guard("shell volume", || Ok(shell.robust_triangulation_within(deflection).to_polygon().volume())).ok()?;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
|
||||
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
|
||||
/// orientation mesh produced is reused instead of meshing the body again.
|
||||
@@ -625,6 +646,7 @@ impl Body {
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: Some((tol, missing)),
|
||||
mesh: None,
|
||||
@@ -666,6 +688,7 @@ impl Body {
|
||||
Ok(Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse,
|
||||
mesh: None,
|
||||
@@ -695,7 +718,12 @@ impl Body {
|
||||
return Err(KernelError::Invalid("degenerate placement".into()));
|
||||
}
|
||||
let rigid = crate::ops::rigid_placement(&a);
|
||||
Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())
|
||||
let out = Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())?;
|
||||
// A placement that does not change distances leaves the boundaries exactly as far off
|
||||
// their surfaces as they were, so the file's own figure still describes them (see
|
||||
// [`Body::boundary_tol`]); one that scales the part does not, and the copy declares
|
||||
// nothing. The same rule as the mesh count above.
|
||||
Ok(if rigid { out.with_boundary_tol(self.boundary_tol) } else { out })
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
|
||||
@@ -981,6 +1009,14 @@ impl Body {
|
||||
self.color
|
||||
}
|
||||
|
||||
/// The same body, with how far its boundaries may lie off their surfaces (mm) as the file it
|
||||
/// was read from declares (see [`Body::boundary_tol`]). A value that is not finite and above
|
||||
/// zero clears the declaration, which puts the body back on the kernel's own bound.
|
||||
pub(crate) fn with_boundary_tol(mut self, tol: Option<f64>) -> Body {
|
||||
self.boundary_tol = tol.filter(|t| t.is_finite() && *t > 0.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// The same body with a display colour.
|
||||
pub fn with_color(mut self, c: Option<[f32; 3]>) -> Body {
|
||||
self.color = c.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0)));
|
||||
@@ -1230,15 +1266,19 @@ impl Body {
|
||||
if let Some(m) = &self.mesh {
|
||||
return Ok(m.to_mesh());
|
||||
}
|
||||
// A face trimmed by a helix (a modelled thread from another CAD system) turns more than
|
||||
// once around its cylinder; truck meshes it with triangles across the solid (#1289).
|
||||
// Such a body is meshed from its display plan, which cuts the face along a seam.
|
||||
if guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid))).unwrap_or(false) {
|
||||
// Two kinds of face truck cannot mesh in its surface's parameters: one trimmed by a
|
||||
// helix, which turns more than once around its cylinder and comes out with triangles
|
||||
// across the solid (#1289), and one that runs across the join of a B-spline surface that
|
||||
// closes on itself, which comes out folded over itself or with a hole in it.
|
||||
// Such a body is meshed from its display plan, which cuts and unwraps those faces.
|
||||
let planned =
|
||||
guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid) || crate::tess::seam::closes_on_itself(&self.solid)));
|
||||
if planned.unwrap_or(false) {
|
||||
let turn = (angle.is_finite() && angle > 0.0)
|
||||
.then(|| crate::tess::size::Turn { angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
|
||||
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn)) {
|
||||
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol)) {
|
||||
Ok((plan, _)) => return Ok(plan.evaluate()),
|
||||
Err(e) => log::debug!("helical faces: no display plan ({e}); meshing the reference way"),
|
||||
Err(e) => log::debug!("no display plan ({e}); meshing the reference way"),
|
||||
}
|
||||
}
|
||||
let deflection = if angle.is_finite() && angle > 0.0 {
|
||||
@@ -1347,7 +1387,7 @@ impl Body {
|
||||
// The same angle and floor as the reference mesher's angular display mesh.
|
||||
let turn =
|
||||
angle.filter(|a| a.is_finite()).map(|a| crate::tess::size::Turn { angle: a.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
|
||||
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn).map(|(plan, _)| plan))
|
||||
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol).map(|(plan, _)| plan))
|
||||
}
|
||||
|
||||
/// Planar faces whose boundary crosses itself: two of its loops crossing each other, or a
|
||||
@@ -1432,25 +1472,49 @@ impl Body {
|
||||
Ok(crossed)
|
||||
}
|
||||
|
||||
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
|
||||
/// Faces with no triangles of their own, and so not shown: none in a coarse tessellation,
|
||||
/// and none in the display plan either (the plan is what the viewport draws, and it meshes
|
||||
/// faces the reference mesher gives up on). Only faces the coarse tessellation misses are
|
||||
/// looked for in the plan, so a sound body costs no planning.
|
||||
pub fn unmeshed_faces(&self) -> usize {
|
||||
if self.is_mesh() {
|
||||
return 0;
|
||||
}
|
||||
let tol = orientation_tolerance(self.size());
|
||||
if let Some((cached_tol, missing)) = self.coarse_missing
|
||||
&& cached_tol == tol
|
||||
{
|
||||
return missing;
|
||||
// The count [`Body::new`] already took at this tolerance, else the same count made now.
|
||||
let missing = match self.coarse_missing {
|
||||
Some((cached_tol, missing)) if cached_tol == tol => missing,
|
||||
_ => {
|
||||
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
|
||||
let mut has = vec![false; self.face_count()];
|
||||
for f in &m.tri_face {
|
||||
if let Some(h) = has.get_mut(*f as usize) {
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
has.iter().filter(|h| !**h).count()
|
||||
}
|
||||
};
|
||||
// A body the coarse mesh covers whole is shown whole: no plan is made for it, whether the
|
||||
// count came from the cache or from the mesh above.
|
||||
if missing == 0 {
|
||||
return 0;
|
||||
}
|
||||
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
|
||||
self.unmeshed_in_plan(tol).unwrap_or(missing)
|
||||
}
|
||||
|
||||
/// Faces the display plan leaves without triangles; `None` when the body has no plan.
|
||||
fn unmeshed_in_plan(&self, tol: f64) -> Option<usize> {
|
||||
let plan = self.display_plan(tol).ok()?;
|
||||
let mut has = vec![false; self.face_count()];
|
||||
for f in &m.tri_face {
|
||||
if let Some(h) = has.get_mut(*f as usize) {
|
||||
for f in &plan.faces {
|
||||
if !f.triangles.is_empty()
|
||||
&& let Some(h) = has.get_mut(f.face as usize)
|
||||
{
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
has.iter().filter(|h| !**h).count()
|
||||
Some(has.iter().filter(|h| !**h).count())
|
||||
}
|
||||
|
||||
/// Display mesh: like [`Body::tessellate`], with seam edges flagged. Meshed from the
|
||||
|
||||
+47
-12
@@ -1986,6 +1986,19 @@ fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
|
||||
}
|
||||
|
||||
fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<(Vec<mt::Wire>, Vec<Vec<Vec<usize>>>)> {
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
// Per section, the profile segments under each of its wire's edges.
|
||||
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
for (p, r) in loft_section_loops(sections, reversed)? {
|
||||
wires.push(uniform_wire(&p, &r.lp)?);
|
||||
edge_sources.push(r.from);
|
||||
}
|
||||
Ok((wires, edge_sources))
|
||||
}
|
||||
|
||||
/// The sections of a loft as the wires are built from them: in loft order, oriented alike,
|
||||
/// re-split to the same number of segments and started where they line up best.
|
||||
pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
|
||||
let n = sections.iter().map(|(_, l)| l.lp.segs.len()).max().unwrap_or(0);
|
||||
if n == 0 || n > MAX_SEGS {
|
||||
return Err(KernelError::Invalid("empty section".into()));
|
||||
@@ -2000,28 +2013,51 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
|
||||
// with the most segments.
|
||||
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
|
||||
let parallel = ordered.iter().all(|(p, _)| p.normal().dot(n0).abs() > 1.0 - 1e-9);
|
||||
// Whether a section faces the first one's way is followed neighbour to neighbour, not by its
|
||||
// normal against the first's: planes turning about an axis pass 90 degrees and would be
|
||||
// flipped, folding the loft (#2847).
|
||||
let mut facing = true;
|
||||
let mut before = n0;
|
||||
let oriented: Vec<(Plane, NumberedLoop)> = ordered
|
||||
.iter()
|
||||
.map(|(p, l)| {
|
||||
let ccw = p.normal().dot(n0) >= 0.0;
|
||||
(*p, l.oriented(!ccw))
|
||||
let n = p.normal();
|
||||
if n.dot(before) < 0.0 {
|
||||
facing = !facing;
|
||||
}
|
||||
before = n;
|
||||
(*p, l.oriented(!facing))
|
||||
})
|
||||
.collect();
|
||||
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
|
||||
let lead_c = lead.0.to_world(lead.1.lp.centroid());
|
||||
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
|
||||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||||
// Per section, the profile segments under each of its wire's edges.
|
||||
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
|
||||
// The share of the lead's perimeter before each of its vertices: where a circle section
|
||||
// breaks when the lead's vertex directions do not go round in order.
|
||||
let lead_len: f64 = lead.1.lp.segs.iter().map(Seg2::length).sum();
|
||||
let fractions: Vec<f64> = lead
|
||||
.1
|
||||
.lp
|
||||
.segs
|
||||
.iter()
|
||||
.scan(0.0, |before, s| {
|
||||
let f = *before / lead_len;
|
||||
*before += s.length();
|
||||
Some(f)
|
||||
})
|
||||
.collect();
|
||||
let mut out: Vec<(Plane, NumberedLoop)> = Vec::new();
|
||||
for (p, l) in &oriented {
|
||||
let c = l.lp.centroid();
|
||||
// Break angles: the lead section's vertex directions seen in this plane.
|
||||
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
|
||||
let mut r = l.resample(n, &angles);
|
||||
// Circle resampling creates increasing local angles. An antiparallel sketch
|
||||
// plane must run clockwise locally to keep the same winding in world space.
|
||||
// Curving a run through a right angle does not reverse a section's winding.
|
||||
if parallel && p.normal().dot(n0) < 0.0 {
|
||||
// plane must run clockwise locally to keep the same winding in world space (the lead's
|
||||
// directions go round clockwise there). Curving a run through a right angle does not
|
||||
// reverse a section's winding.
|
||||
let flip = parallel && p.normal().dot(n0) < 0.0;
|
||||
let mut r = l.resample(n, &angles, &fractions, flip);
|
||||
if flip {
|
||||
r = r.oriented(true);
|
||||
}
|
||||
// Start where every vertex lines up best with the lead's vertex of the same number (the
|
||||
@@ -2036,10 +2072,9 @@ fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Re
|
||||
if let Some(k) = (0..m).min_by(|a, b| fit(*b).total_cmp(&fit(*a))) {
|
||||
r.rotate_left(k);
|
||||
}
|
||||
wires.push(uniform_wire(p, &r.lp)?);
|
||||
edge_sources.push(r.from);
|
||||
out.push((*p, r));
|
||||
}
|
||||
Ok((wires, edge_sources))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ruled faces between each two neighbouring section wires; each comes from the section edges
|
||||
|
||||
@@ -228,8 +228,10 @@ fn a_small_hole_on_a_large_plate_has_a_round_rim() {
|
||||
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
|
||||
assert_eq!(segments, 18);
|
||||
for z in [0.0, 2.0] {
|
||||
// The chord tolerance alone: each half circle is one segment, the rim a line.
|
||||
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
|
||||
// The chord tolerance alone leaves each half circle one segment, the rim a line no face
|
||||
// can be bounded by: its edges are divided again, and the rim stays a coarse polygon.
|
||||
let (n, _) = rim(&coarse, centre, 2.0, z);
|
||||
assert!((3..segments).contains(&n), "{n} segments around the rim at the chord tolerance");
|
||||
let (n, widest) = rim(&round, centre, 2.0, z);
|
||||
assert!(n >= segments, "{n} segments around the rim at z = {z}");
|
||||
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
|
||||
@@ -291,9 +293,12 @@ fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
|
||||
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
|
||||
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
|
||||
for z in [0.0, 2.0] {
|
||||
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
|
||||
// At the chord tolerance alone each rim is divided only as far as a face can be bounded
|
||||
// by it (two segments, a line, are divided again).
|
||||
assert!(boundary(&coarse, z) < 6 * centres.len(), "{}", boundary(&coarse, z));
|
||||
let n = boundary(&round, z);
|
||||
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
|
||||
let (rounder, chord) = (rim(&round, centres[0], 2.0, z).0, rim(&coarse, centres[0], 2.0, z).0);
|
||||
assert!(rounder >= chord, "the rims are at least as round as the chord tolerance leaves them: {rounder} < {chord}");
|
||||
assert!(n <= budget, "{n} segments bound the face at z = {z}");
|
||||
}
|
||||
// Every rim got the same share, and the walls follow their rims.
|
||||
|
||||
+198
-73
@@ -9,6 +9,8 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
mod assembly;
|
||||
|
||||
/// Largest IGES file we read.
|
||||
pub const MAX_IGES_BYTES: usize = 512 << 20;
|
||||
const MAX_ENTITIES: usize = 2_000_000;
|
||||
@@ -427,6 +429,8 @@ struct Tr<'a> {
|
||||
curve_work: usize,
|
||||
/// Composite curves (102) on the path being expanded.
|
||||
composites: Vec<i64>,
|
||||
/// Farthest a trim loop's end was moved to close a gap (mm; see `close_gaps`).
|
||||
snapped: f64,
|
||||
}
|
||||
|
||||
fn xf_key(x: &Xf) -> [u64; 12] {
|
||||
@@ -660,6 +664,8 @@ impl<'a> Tr<'a> {
|
||||
self.nurbs_curve_in(seq, x, self.f.unit)
|
||||
}
|
||||
fn nurbs_curve_in(&self, seq: i64, x: &Xf, unit: f64) -> R<Nurbs> {
|
||||
/// Knot spans shorter than this are moved onto (0, 1).
|
||||
const MIN_KNOT_SPAN: f64 = 1e-4;
|
||||
let k = self.i(seq, 1)?;
|
||||
let m = self.i(seq, 2)?;
|
||||
if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 {
|
||||
@@ -685,11 +691,21 @@ impl<'a> Tr<'a> {
|
||||
if knots.windows(2).any(|w| w[1] < w[0]) {
|
||||
return Err(format!("entity {seq}: knots out of order"));
|
||||
}
|
||||
let nb = Nurbs { deg: m, knots, pts };
|
||||
let mut nb = Nurbs { deg: m, knots, pts };
|
||||
let (a, b) = nb.range();
|
||||
if !(b > a) {
|
||||
return Err(format!("entity {seq}: empty parameter range"));
|
||||
}
|
||||
// Some exporters write a curve over a knot span of 1e-7 or less. The STEP side reads a
|
||||
// span that small as a single value and refused the curve, losing the faces it bounds.
|
||||
// Moving and scaling the knots onto (0, 1) leaves the curve's shape as it is; nothing
|
||||
// else refers to its parameters.
|
||||
if b - a < MIN_KNOT_SPAN {
|
||||
let scale = 1.0 / (b - a);
|
||||
for k in &mut nb.knots {
|
||||
*k = (*k - a) * scale;
|
||||
}
|
||||
}
|
||||
Ok(nb)
|
||||
}
|
||||
|
||||
@@ -1020,24 +1036,35 @@ struct Sewer {
|
||||
}
|
||||
|
||||
impl Sewer {
|
||||
fn vertex(&mut self, p: V3) -> usize {
|
||||
fn key(&self, p: V3) -> [i64; 3] {
|
||||
// Neighbouring buckets are this key plus or minus one, so leave room for that: a
|
||||
// coordinate near f64's range saturates the cast and the addition would overflow.
|
||||
let c = |x: f64| (x / self.tol).floor().clamp(-9e15, 9e15) as i64;
|
||||
let key = [c(p[0]), c(p[1]), c(p[2])];
|
||||
[c(p[0]), c(p[1]), c(p[2])]
|
||||
}
|
||||
/// The vertex within the tolerance of `p`, if there is one.
|
||||
fn find(&self, p: V3) -> Option<usize> {
|
||||
let key = self.key(p);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
if let Some(list) = self.grid.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) {
|
||||
for &i in list {
|
||||
if self.verts.get(i).is_some_and(|q| norm(sub(*q, p)) <= self.tol) {
|
||||
return i;
|
||||
return Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn vertex(&mut self, p: V3) -> usize {
|
||||
if let Some(i) = self.find(p) {
|
||||
return i;
|
||||
}
|
||||
let key = self.key(p);
|
||||
self.verts.push(p);
|
||||
self.grid.entry(key).or_default().push(self.verts.len() - 1);
|
||||
self.verts.len() - 1
|
||||
@@ -1213,12 +1240,21 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
}
|
||||
let f = parse(text)?;
|
||||
let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5);
|
||||
let mut t =
|
||||
Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new(), curve_work: 0, composites: Vec::new() };
|
||||
let mut t = Tr {
|
||||
f: &f,
|
||||
o: Out { lines: Vec::new() },
|
||||
warnings: Vec::new(),
|
||||
tol,
|
||||
surf_cache: HashMap::new(),
|
||||
curve_work: 0,
|
||||
composites: Vec::new(),
|
||||
snapped: 0.0,
|
||||
};
|
||||
// Header: units and context.
|
||||
let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
|
||||
let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
|
||||
let sr = t.o.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
|
||||
let unc_line = t.o.lines.len();
|
||||
let unc = t.o.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol)));
|
||||
let ctx = t.o.add(format!(
|
||||
"(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr}))REPRESENTATION_CONTEXT('',''))"
|
||||
@@ -1228,77 +1264,49 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
// Entities used by others (a B-rep's faces, a trimmed surface's curves) are not roots;
|
||||
// physically dependent entities have status digits 01 in columns 3–4, but files are lax:
|
||||
// collect references instead.
|
||||
let mut solids: Vec<(u64, String)> = Vec::new();
|
||||
let assembly = assembly::Assembly::read(&t, &seqs)?;
|
||||
let mut solids = HashMap::new();
|
||||
let mut styles: Vec<(u64, [f32; 3])> = Vec::new();
|
||||
let mut n = 0usize;
|
||||
let mut fallback = String::new();
|
||||
let watch = crate::watch::current();
|
||||
for &s in &seqs {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
let Some(de) = f.des.get(&s) else { continue };
|
||||
if de.typ != 186 {
|
||||
continue;
|
||||
}
|
||||
n += 1;
|
||||
// A name property (406 form 15) among the solid's trailing pointers, else the label.
|
||||
let named = f.params.get(&s).and_then(|p| {
|
||||
p.iter().skip(4).filter_map(|x| int(x)).filter(|x| *x > 0).find_map(|ptr| {
|
||||
let d = f.des.get(&usize::try_from(ptr).ok()?)?;
|
||||
(d.typ == 406 && d.form == 15).then(|| f.params.get(&usize::try_from(ptr).ok()?)?.get(2).cloned()).flatten()
|
||||
})
|
||||
});
|
||||
let name = match named.filter(|x| !x.trim().is_empty()) {
|
||||
Some(x) => x.trim().to_string(),
|
||||
None if de.label.is_empty() => format!("Body{n}"),
|
||||
None => de.label.clone(),
|
||||
let name = if let Some(name) = assembly::entity_name(&f, s) {
|
||||
name
|
||||
} else {
|
||||
fallback.clear();
|
||||
let _ = write!(fallback, "Body{n}");
|
||||
fallback.as_str()
|
||||
};
|
||||
match brep(&mut t, s as i64, &mut styles) {
|
||||
Ok((id, color)) => {
|
||||
let named = {
|
||||
// Re-label the solid with its name.
|
||||
let line = t.o.lines.get(id as usize - 1).cloned().unwrap_or_default();
|
||||
line.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1)
|
||||
};
|
||||
if let Some(l) = t.o.lines.get_mut(id as usize - 1) {
|
||||
*l = named;
|
||||
*l = l.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1);
|
||||
}
|
||||
if let Some(c) = color {
|
||||
styles.push((id, c));
|
||||
}
|
||||
solids.push((id, name));
|
||||
solids.insert(s, id);
|
||||
}
|
||||
Err(e) => t.warn(format!("solid {name}: not read: {e}")),
|
||||
}
|
||||
}
|
||||
// Trimmed and bounded surfaces not used by anything else: sewn into shells.
|
||||
let mut faces = Vec::new();
|
||||
let mut sw = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
|
||||
if solids.is_empty() {
|
||||
for &s in &seqs {
|
||||
let Some(de) = f.des.get(&s) else { continue };
|
||||
if de.typ != 144 && de.typ != 143 && !(de.typ == 128 && is_root(&f, s)) {
|
||||
continue;
|
||||
}
|
||||
match trimmed(&mut t, &mut sw, s as i64) {
|
||||
Ok(fb) => faces.push(fb),
|
||||
Err(e) => t.warn(format!("surface {s}: not read: {e}")),
|
||||
}
|
||||
}
|
||||
if !faces.is_empty() {
|
||||
orient(&mut faces);
|
||||
let (shell, closed) = write_shell(&mut t.o, &sw, &faces, &mut styles);
|
||||
let item = if closed {
|
||||
t.o.add(format!("MANIFOLD_SOLID_BREP('Body1',#{shell})"))
|
||||
} else {
|
||||
t.warn("the surfaces do not close into a solid: imported as an open body");
|
||||
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('Body1',(#{shell}))"))
|
||||
};
|
||||
solids.push((item, "Body1".into()));
|
||||
assembly.write(&mut t, &seqs, &solids, &mut styles, ctx)?;
|
||||
if t.snapped > 0.0 {
|
||||
// The STEP reader takes vertices up to ten times the stated accuracy off their curves:
|
||||
// state enough to cover the ends `close_gaps` moved, and no more.
|
||||
if let Some(l) = t.o.lines.get_mut(unc_line) {
|
||||
*l = format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol.max(t.snapped * 0.2)));
|
||||
}
|
||||
}
|
||||
if solids.is_empty() {
|
||||
let why = t.warnings.first().cloned().unwrap_or_else(|| "no solids or trimmed surfaces".into());
|
||||
return Err(format!("IGES import: nothing to read ({why})"));
|
||||
}
|
||||
let items = solids.iter().map(|(i, _)| format!("#{i}")).collect::<Vec<_>>().join(",");
|
||||
t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})"));
|
||||
for (item, c) in styles {
|
||||
let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)));
|
||||
let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
|
||||
@@ -1319,23 +1327,140 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
|
||||
Ok((s, t.warnings))
|
||||
}
|
||||
|
||||
/// A surface entity nobody refers to (a bare B-spline patch is a face of its own).
|
||||
fn is_root(f: &Iges, seq: usize) -> bool {
|
||||
let target = seq.to_string();
|
||||
!f.params
|
||||
.iter()
|
||||
.any(|(s, p)| *s != seq && f.des.get(s).is_some_and(|d| d.typ != 406 && d.typ != 402) && p.iter().skip(1).any(|x| x.trim() == target))
|
||||
/// Sew surfaces within their owning group, keeping different parts separate.
|
||||
fn sew_surfaces(
|
||||
t: &mut Tr,
|
||||
seqs: impl Iterator<Item = usize>,
|
||||
name: &str,
|
||||
styles: &mut Vec<(u64, [f32; 3])>,
|
||||
faces: &mut Vec<FaceB>,
|
||||
sw: &mut Sewer,
|
||||
) -> R<Option<u64>> {
|
||||
faces.clear();
|
||||
sw.verts.clear();
|
||||
sw.grid.clear();
|
||||
sw.edges.clear();
|
||||
sw.by_ends.clear();
|
||||
let watch = crate::watch::current();
|
||||
let mut drafts = Vec::new();
|
||||
for seq in seqs {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
match trimmed(t, sw.tol, seq as i64) {
|
||||
Ok(face) => drafts.push(face),
|
||||
Err(e) => t.warn(format!("surface {seq}: not read: {e}")),
|
||||
}
|
||||
}
|
||||
close_gaps(t, &mut drafts, sw.tol);
|
||||
for d in drafts {
|
||||
let loops = d.loops.iter().map(|l| l.iter().map(|s| sw.edge(s)).collect()).collect();
|
||||
faces.push(FaceB { surface: d.surface, sense: true, loops, color: d.color });
|
||||
}
|
||||
if faces.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
orient(faces);
|
||||
let (shell, closed) = write_shell(&mut t.o, sw, faces, styles);
|
||||
let name = name.replace('\'', "''");
|
||||
Ok(Some(if closed {
|
||||
t.o.add(format!("MANIFOLD_SOLID_BREP('{name}',#{shell})"))
|
||||
} else {
|
||||
t.warn("the surfaces do not close into a solid: imported as an open body");
|
||||
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('{name}',(#{shell}))"))
|
||||
}))
|
||||
}
|
||||
|
||||
/// A face read from a trimmed or bounded surface, its loops not yet sewn.
|
||||
struct Draft {
|
||||
surface: u64,
|
||||
loops: Vec<Vec<Seg>>,
|
||||
color: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// Gaps between the pieces of a trim loop wider than this (mm) are left open: past it, the ends
|
||||
/// may be different corners of the model rather than one corner written loosely.
|
||||
const MAX_TRIM_GAP: f64 = 0.1;
|
||||
/// ...and a gap must also be at most this share of the shorter piece on either side.
|
||||
const MAX_TRIM_GAP_SHARE: f64 = 0.05;
|
||||
|
||||
/// Close the small gaps between consecutive pieces of the faces' trim loops. Some exporters
|
||||
/// write each piece's model-space curve separately, and consecutive pieces can then miss by a
|
||||
/// few micrometres to a tenth of a millimetre, well past the file's stated resolution: the loop
|
||||
/// does not close, its face is lost and the shell stays open. The two ends of each such gap, and
|
||||
/// every other piece end at either of them (the neighbouring faces at that corner), move to
|
||||
/// their mean, so all the faces there sew to one vertex. A gap wider than `MAX_TRIM_GAP`, or not
|
||||
/// small next to its pieces, is left open; the sewing tolerance stays the file's.
|
||||
fn close_gaps(t: &mut Tr, faces: &mut [Draft], tol: f64) {
|
||||
let length = |s: &Seg| norm(sub(s.q, s.a)) + norm(sub(s.mid, s.q)) + norm(sub(s.q3, s.mid)) + norm(sub(s.b, s.q3));
|
||||
// The gaps' ends as vertices, joined into groups (union-find).
|
||||
let mut ends = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
|
||||
let mut parent: Vec<usize> = Vec::new();
|
||||
let root = |parent: &[usize], mut i: usize| {
|
||||
while let Some(&p) = parent.get(i).filter(|p| **p != i) {
|
||||
i = p;
|
||||
}
|
||||
i
|
||||
};
|
||||
for l in faces.iter().flat_map(|f| f.loops.iter()) {
|
||||
for (i, prev) in l.iter().enumerate() {
|
||||
let Some(next) = l.get((i + 1) % l.len()) else { continue };
|
||||
let gap = norm(sub(next.a, prev.b));
|
||||
if gap <= tol || gap > MAX_TRIM_GAP || gap > MAX_TRIM_GAP_SHARE * length(prev).min(length(next)) {
|
||||
continue;
|
||||
}
|
||||
let (a, b) = (ends.vertex(prev.b), ends.vertex(next.a));
|
||||
while parent.len() < ends.verts.len() {
|
||||
parent.push(parent.len());
|
||||
}
|
||||
let (ra, rb) = (root(&parent, a), root(&parent, b));
|
||||
if let Some(x) = parent.get_mut(ra) {
|
||||
*x = rb;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ends.verts.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Each group's mean; a group that spreads wider than a gap may be is left alone.
|
||||
let mut sums: HashMap<usize, (V3, usize)> = HashMap::new();
|
||||
for (i, p) in ends.verts.iter().enumerate() {
|
||||
let e = sums.entry(root(&parent, i)).or_insert(([0.0; 3], 0));
|
||||
e.0 = add(e.0, *p);
|
||||
e.1 += 1;
|
||||
}
|
||||
let mean: HashMap<usize, V3> = sums.into_iter().map(|(r, (s, n))| (r, mul(s, 1.0 / n as f64))).collect();
|
||||
let mut to: Vec<Option<V3>> = ends.verts.iter().enumerate().map(|(i, _)| mean.get(&root(&parent, i)).copied()).collect();
|
||||
for (i, p) in ends.verts.iter().enumerate() {
|
||||
if to.get(i).copied().flatten().is_some_and(|m| norm(sub(m, *p)) > MAX_TRIM_GAP / 2.0) {
|
||||
let r = root(&parent, i);
|
||||
for (k, x) in to.iter_mut().enumerate() {
|
||||
if root(&parent, k) == r {
|
||||
*x = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut moved = 0.0f64;
|
||||
let mut snap = |p: &mut V3| {
|
||||
if let Some(m) = ends.find(*p).and_then(|i| to.get(i).copied().flatten()) {
|
||||
moved = moved.max(norm(sub(m, *p)));
|
||||
*p = m;
|
||||
}
|
||||
};
|
||||
for s in faces.iter_mut().flat_map(|f| f.loops.iter_mut()).flat_map(|l| l.iter_mut()) {
|
||||
snap(&mut s.a);
|
||||
snap(&mut s.b);
|
||||
}
|
||||
t.snapped = t.snapped.max(moved);
|
||||
}
|
||||
|
||||
/// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face.
|
||||
fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
|
||||
fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
|
||||
let de = t.de(seq)?;
|
||||
let xf = t.own_xf(seq, 0)?;
|
||||
let color = color_of(t, seq);
|
||||
let loop_of = |_t: &mut Tr, sw: &mut Sewer, segs: Vec<Seg>| -> Vec<(usize, bool)> {
|
||||
let tol = sw.tol;
|
||||
segs.iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).map(|s| sw.edge(s)).collect()
|
||||
};
|
||||
let loop_of = |segs: Vec<Seg>| -> Vec<Seg> { segs.into_iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).collect() };
|
||||
match de.typ {
|
||||
144 => {
|
||||
let surf_seq = t.i(seq, 1)?;
|
||||
@@ -1344,16 +1469,16 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
|
||||
let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize;
|
||||
let mut loops = Vec::new();
|
||||
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? };
|
||||
loops.push(loop_of(t, sw, outer));
|
||||
loops.push(loop_of(outer));
|
||||
for k in 0..n2 {
|
||||
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?;
|
||||
loops.push(loop_of(t, sw, segs));
|
||||
loops.push(loop_of(segs));
|
||||
}
|
||||
loops.retain(|l| !l.is_empty());
|
||||
if loops.is_empty() {
|
||||
return Err("no boundary".into());
|
||||
}
|
||||
Ok(FaceB { surface, sense: true, loops, color })
|
||||
Ok(Draft { surface, loops, color })
|
||||
}
|
||||
143 => {
|
||||
let surf_seq = t.i(seq, 2)?;
|
||||
@@ -1363,19 +1488,19 @@ fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
|
||||
for k in 0..n {
|
||||
let b = t.i(seq, 4 + k)?;
|
||||
let segs = boundary_141(t, b, &xf)?;
|
||||
loops.push(loop_of(t, sw, segs));
|
||||
loops.push(loop_of(segs));
|
||||
}
|
||||
loops.retain(|l| !l.is_empty());
|
||||
if loops.is_empty() {
|
||||
return Err("no boundary".into());
|
||||
}
|
||||
Ok(FaceB { surface, sense: true, loops, color })
|
||||
Ok(Draft { surface, loops, color })
|
||||
}
|
||||
_ => {
|
||||
let surface = t.surface(seq, &Xf::ID)?;
|
||||
let outer = t.natural_boundary(seq, &Xf::ID)?;
|
||||
let l = loop_of(t, sw, outer);
|
||||
Ok(FaceB { surface, sense: true, loops: vec![l], color })
|
||||
let l = loop_of(outer);
|
||||
Ok(Draft { surface, loops: vec![l], color })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
//! IGES groups (402) and subfigures (308/408), from the published IGES specification.
|
||||
//! Definitions become STEP products; instances refer to them without copying geometry.
|
||||
//! Reference: https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nbsir88-3813.pdf
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MAX_GROUPS: usize = 10_000;
|
||||
const MAX_INSTANCES: usize = 100_000;
|
||||
const MAX_REFERENCES: usize = 4 * MAX_ENTITIES;
|
||||
|
||||
struct Group {
|
||||
seq: usize,
|
||||
members: Vec<usize>,
|
||||
}
|
||||
|
||||
struct Instance {
|
||||
definition: usize,
|
||||
transform: Xf,
|
||||
}
|
||||
|
||||
pub(super) struct Assembly {
|
||||
groups: Vec<Group>,
|
||||
indices: HashMap<usize, usize>,
|
||||
instances: HashMap<usize, Instance>,
|
||||
referenced: HashSet<usize>,
|
||||
surface_references: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl Assembly {
|
||||
pub(super) fn read(t: &Tr, seqs: &[usize]) -> R<Self> {
|
||||
let mut out = Self {
|
||||
groups: Vec::new(),
|
||||
indices: HashMap::new(),
|
||||
instances: HashMap::new(),
|
||||
referenced: HashSet::new(),
|
||||
surface_references: HashSet::new(),
|
||||
};
|
||||
let mut references = 0usize;
|
||||
let watch = crate::watch::current();
|
||||
for &seq in seqs {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
let de = t.de(seq as i64)?;
|
||||
if de.typ == 308 || (de.typ == 402 && matches!(de.form, 1 | 7 | 14 | 15)) {
|
||||
if out.groups.len() >= MAX_GROUPS {
|
||||
return Err("too many IGES assembly definitions".into());
|
||||
}
|
||||
let field = if de.typ == 308 { 3 } else { 1 };
|
||||
let count = usize::try_from(t.i(seq as i64, field)?).map_err(|_| "negative IGES group size")?;
|
||||
references = references.saturating_add(count);
|
||||
if count > MAX_LIST || references > MAX_REFERENCES {
|
||||
return Err("IGES assembly references exceed the work limit".into());
|
||||
}
|
||||
let p = t.p(seq as i64)?;
|
||||
let entries = p.get(field + 1..field + 1 + count).ok_or("truncated IGES group")?;
|
||||
let mut members = Vec::with_capacity(count);
|
||||
for entry in entries {
|
||||
let child = int(entry).and_then(|x| usize::try_from(x).ok()).filter(|x| *x > 0).ok_or("invalid IGES group reference")?;
|
||||
t.de(child as i64)?;
|
||||
members.push(child);
|
||||
out.referenced.insert(child);
|
||||
}
|
||||
out.indices.insert(seq, out.groups.len());
|
||||
out.groups.push(Group { seq, members });
|
||||
} else if de.typ == 408 {
|
||||
if out.instances.len() >= MAX_INSTANCES {
|
||||
return Err("too many IGES assembly instances".into());
|
||||
}
|
||||
let definition = usize::try_from(t.i(seq as i64, 1)?).map_err(|_| "invalid IGES subfigure reference")?;
|
||||
if t.de(definition as i64)?.typ != 308 {
|
||||
return Err("IGES instance does not refer to a subfigure definition".into());
|
||||
}
|
||||
let p = t.p(seq as i64)?;
|
||||
let scale = match p.get(5).map(|s| s.trim()) {
|
||||
None | Some("") => 1.0,
|
||||
Some(s) => num(s).filter(|x| *x != 0.0).ok_or("invalid IGES instance scale")?,
|
||||
};
|
||||
let [x, y, z] = t.pt(seq as i64, 2)?;
|
||||
let local = Xf([[scale, 0.0, 0.0, x], [0.0, scale, 0.0, y], [0.0, 0.0, scale, z]]);
|
||||
let transform = t.own_xf(seq as i64, 0)?.then(&local).then(&t.own_xf(definition as i64, 0)?);
|
||||
if !transform.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Err("non-finite IGES instance placement".into());
|
||||
}
|
||||
out.instances.insert(seq, Instance { definition, transform });
|
||||
out.referenced.insert(definition);
|
||||
}
|
||||
}
|
||||
// Index geometry references once instead of rescanning all parameters for each patch.
|
||||
for (&seq, params) in &t.f.params {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
if t.f.des.get(&seq).is_some_and(|d| matches!(d.typ, 308 | 402 | 406 | 408)) {
|
||||
continue;
|
||||
}
|
||||
for p in params.iter().skip(1) {
|
||||
let p = p.trim();
|
||||
if p.starts_with('0') {
|
||||
continue;
|
||||
}
|
||||
if let Ok(id) = p.parse::<usize>()
|
||||
&& id != seq
|
||||
&& t.f.des.get(&id).is_some_and(|d| d.typ == 128)
|
||||
{
|
||||
out.surface_references.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut depths = HashMap::with_capacity(out.groups.len());
|
||||
let mut placements = out.groups.iter().filter(|g| !out.referenced.contains(&g.seq)).count()
|
||||
+ out.instances.keys().filter(|s| !out.referenced.contains(*s)).count();
|
||||
for group in &out.groups {
|
||||
placements += group.members.iter().filter(|s| out.indices.contains_key(*s) || out.instances.contains_key(*s)).count();
|
||||
if placements > MAX_INSTANCES {
|
||||
return Err("too many IGES assembly placements".into());
|
||||
}
|
||||
out.depth(group.seq, 0, &mut depths)?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn depth(&self, seq: usize, depth: usize, seen: &mut HashMap<usize, Option<usize>>) -> R<usize> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err("IGES assembly nesting is too deep".into());
|
||||
}
|
||||
if let Some(height) = seen.get(&seq) {
|
||||
return height.ok_or_else(|| "cyclic IGES assembly".into());
|
||||
}
|
||||
seen.insert(seq, None);
|
||||
let group = self.indices.get(&seq).and_then(|i| self.groups.get(*i)).ok_or("missing IGES assembly definition")?;
|
||||
let mut height = 1;
|
||||
for child in &group.members {
|
||||
let definition = self.instances.get(child).map(|i| i.definition).unwrap_or(*child);
|
||||
if self.indices.contains_key(&definition) {
|
||||
height = height.max(1 + self.depth(definition, depth + 1, seen)?);
|
||||
}
|
||||
}
|
||||
if height > MAX_DEPTH {
|
||||
return Err("IGES assembly nesting is too deep".into());
|
||||
}
|
||||
seen.insert(seq, Some(height));
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
pub(super) fn write(&self, t: &mut Tr, seqs: &[usize], solids: &HashMap<usize, u64>, styles: &mut Vec<(u64, [f32; 3])>, ctx: u64) -> R<()> {
|
||||
let app = t.o.add("APPLICATION_CONTEXT('mechanical design')");
|
||||
let pc = t.o.add(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
|
||||
let dc = t.o.add(format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));
|
||||
let origin = t.o.placement([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
|
||||
let mut products = HashMap::with_capacity(self.groups.len() + 1);
|
||||
let mut items = Vec::new();
|
||||
let mut list = String::new();
|
||||
let mut fallback = String::new();
|
||||
let mut made = 0usize;
|
||||
let f = t.f;
|
||||
let mut faces = Vec::new();
|
||||
let mut sewer = Sewer { tol: t.tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
|
||||
let watch = crate::watch::current();
|
||||
// A synthetic root holds loose geometry and top-level groups/instances.
|
||||
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
let seq = group.map(|g| g.seq).unwrap_or(0);
|
||||
let name = if seq == 0 {
|
||||
"Import"
|
||||
} else if let Some(name) = entity_name(f, seq) {
|
||||
name
|
||||
} else {
|
||||
fallback.clear();
|
||||
let _ = write!(fallback, "Group {seq}");
|
||||
fallback.as_str()
|
||||
};
|
||||
items.clear();
|
||||
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
|
||||
items.extend(members.iter().filter(|s| seq != 0 || !self.referenced.contains(*s)).filter_map(|s| solids.get(s).copied()));
|
||||
let surfaces = members
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| seq != 0 || !self.referenced.contains(s))
|
||||
.filter(|s| f.des.get(s).is_some_and(|d| matches!(d.typ, 143 | 144) || (d.typ == 128 && !self.surface_references.contains(s))));
|
||||
if let Some(item) = sew_surfaces(t, surfaces, if seq == 0 { "Body1" } else { name }, styles, &mut faces, &mut sewer)? {
|
||||
items.push(item);
|
||||
}
|
||||
made += items.len();
|
||||
list.clear();
|
||||
let _ = write!(list, "#{origin}");
|
||||
for item in &items {
|
||||
let _ = write!(list, ",#{item}");
|
||||
}
|
||||
let rep = t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({list}),#{ctx})"));
|
||||
let name = name.replace('\'', "''");
|
||||
let product = t.o.add(format!("PRODUCT('{name}','{name}','',(#{pc}))"));
|
||||
let formation = t.o.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
|
||||
let pd = t.o.add(format!("PRODUCT_DEFINITION('design','',#{formation},#{dc})"));
|
||||
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
|
||||
t.o.add(format!("SHAPE_DEFINITION_REPRESENTATION(#{shape},#{rep})"));
|
||||
products.insert(seq, (pd, rep));
|
||||
}
|
||||
if made == 0 {
|
||||
let why = t.warnings.first().map(String::as_str).unwrap_or("no solids or trimmed surfaces");
|
||||
return Err(format!("IGES import: nothing to read ({why})"));
|
||||
}
|
||||
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
|
||||
let parent = group.map(|g| g.seq).unwrap_or(0);
|
||||
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
|
||||
for &child in members {
|
||||
if watch.as_ref().is_some_and(|w| w.cancelled()) {
|
||||
return Err("IGES import cancelled".into());
|
||||
}
|
||||
if parent == 0 && self.referenced.contains(&child) {
|
||||
continue;
|
||||
}
|
||||
let (definition, transform) = match self.instances.get(&child) {
|
||||
Some(i) => (i.definition, i.transform),
|
||||
None if self.indices.contains_key(&child) => (child, t.own_xf(child as i64, 0)?),
|
||||
_ => continue,
|
||||
};
|
||||
let (parent_pd, parent_rep) = products.get(&parent).copied().ok_or("missing IGES assembly parent")?;
|
||||
let (child_pd, child_rep) = products.get(&definition).copied().ok_or("missing IGES assembly child")?;
|
||||
let label = entity_name(f, child).unwrap_or("").replace('\'', "''");
|
||||
let nauo = t.o.add(format!("NEXT_ASSEMBLY_USAGE_OCCURRENCE('{child}','{label}','',#{parent_pd},#{child_pd},$)"));
|
||||
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
|
||||
let op = transformation(&mut t.o, transform)?;
|
||||
let rr = t.o.add(format!("(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{op})SHAPE_REPRESENTATION_RELATIONSHIP())"));
|
||||
t.o.add(format!("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{shape})"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn transformation(out: &mut Out, xf: Xf) -> R<u64> {
|
||||
if !xf.0.iter().flatten().all(|v| v.is_finite()) {
|
||||
return Err("non-finite IGES assembly placement".into());
|
||||
}
|
||||
let [x, y, z] = [xf.dir([1.0, 0.0, 0.0]), xf.dir([0.0, 1.0, 0.0]), xf.dir([0.0, 0.0, 1.0])];
|
||||
let [sx, sy, sz] = [norm(x), norm(y), norm(z)];
|
||||
let scale = xf.scale();
|
||||
if [sx, sy, sz, scale].iter().any(|s| !s.is_finite() || *s <= 1e-300) {
|
||||
return Err("singular IGES assembly placement".into());
|
||||
}
|
||||
let (x, y, z, o) = (out.dir(mul(x, 1.0 / sx)), out.dir(mul(y, 1.0 / sy)), out.dir(mul(z, 1.0 / sz)), out.point(xf.point([0.0; 3])));
|
||||
Ok(out.add(format!("CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#{x},#{y},#{o},{},#{z},{},{})", r(sx), r(sy), r(sz))))
|
||||
}
|
||||
|
||||
/// Name properties follow the associativity pointers; member pointers are not properties.
|
||||
pub(super) fn entity_name(f: &Iges, seq: usize) -> Option<&str> {
|
||||
let de = f.des.get(&seq)?;
|
||||
let p = f.params.get(&seq)?;
|
||||
let count = |i: usize| p.get(i).and_then(|s| int(s)).and_then(|x| usize::try_from(x).ok()).filter(|n| *n <= MAX_LIST);
|
||||
let end = match de.typ {
|
||||
186 => count(3)?.checked_mul(2)?.checked_add(4)?,
|
||||
308 => count(3)?.checked_add(4)?,
|
||||
402 => count(1)?.checked_add(2)?,
|
||||
408 => 6,
|
||||
_ => p.len(),
|
||||
};
|
||||
let property = (|| {
|
||||
let after = end.checked_add(count(end)?)?.checked_add(1)?;
|
||||
let n = count(after)?;
|
||||
p.get(after + 1..after + 1 + n)?.iter().find_map(|s| {
|
||||
let id = int(s).and_then(|x| usize::try_from(x).ok())?;
|
||||
let d = f.des.get(&id)?;
|
||||
if d.typ != 406 || d.form != 15 {
|
||||
return None;
|
||||
}
|
||||
f.params.get(&id)?.get(2).and_then(|s| name_text(s))
|
||||
})
|
||||
})();
|
||||
property.or_else(|| if de.typ == 308 { p.get(2).and_then(|s| name_text(s)) } else { None }).or_else(|| name_text(&de.label))
|
||||
}
|
||||
|
||||
/// Match the component-name limit before copying or repeating a shared name property.
|
||||
fn name_text(s: &str) -> Option<&str> {
|
||||
let end = s.char_indices().nth(128).map(|(i, _)| i).unwrap_or(s.len());
|
||||
let name = s.get(..end)?.trim();
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
@@ -156,15 +156,116 @@ fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
|
||||
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
|
||||
}
|
||||
|
||||
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
|
||||
/// boundaries are lines: sewn into a closed solid.
|
||||
/// An exported box inside a named group and nested subfigures, placed twice. The second
|
||||
/// placement composes its translation and scale with a directory transformation.
|
||||
fn assembly_file(form: i64, scale: f64) -> String {
|
||||
let body = box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
|
||||
let text = iges_export_bodies(&[ExportBody { name: "Body1".into(), body: &body, color: None }], &StepHeader::default()).unwrap();
|
||||
let f = parse(&text).unwrap();
|
||||
let mut ids: Vec<_> = f.des.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
let solid = *ids.iter().find(|&&s| f.des[&s].typ == 186).unwrap();
|
||||
let mut transforms: HashMap<_, _> = f.des.iter().map(|(seq, d)| (*seq, d.xform)).collect();
|
||||
let mut ents: Vec<_> = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, seq)| {
|
||||
assert_eq!(*seq, 2 * k + 1);
|
||||
let d = &f.des[seq];
|
||||
let p = f.params[seq]
|
||||
.iter()
|
||||
.skip(1)
|
||||
.map(|s| if s.is_empty() || num(s).is_some() { s.clone() } else { format!("{}H{s}", s.chars().count()) })
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
(d.typ, d.form, p)
|
||||
})
|
||||
.collect();
|
||||
let definition = 2 * ents.len() + 1;
|
||||
ents.push((308, 0, format!("0,8HLead rod,1,{solid}")));
|
||||
let first = 2 * ents.len() + 1;
|
||||
ents.push((408, 0, format!("{definition},10.,0.,0.,1.")));
|
||||
let rotation = 2 * ents.len() + 1;
|
||||
ents.push((124, 0, "0.,-1.,0.,100.,1.,0.,0.,0.,0.,0.,1.,0.".into()));
|
||||
let second = 2 * ents.len() + 1;
|
||||
ents.push((408, 0, format!("{definition},0.,20.,0.,{scale}")));
|
||||
transforms.insert(second, rotation as i64);
|
||||
let frame = 2 * ents.len() + 1;
|
||||
ents.push((308, 0, format!("1,5HFrame,2,{first},{second}")));
|
||||
let property = 2 * ents.len() + 1;
|
||||
ents.push((406, 15, "1,6HMotion".into()));
|
||||
ents.push((402, form, format!("1,{frame},0,1,{property}")));
|
||||
iges_file(&ents)
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.as_bytes()[72] != b'D' {
|
||||
return line.to_string();
|
||||
}
|
||||
let seq: usize = line[73..80].trim().parse().unwrap();
|
||||
match transforms.get(&seq) {
|
||||
Some(xf) => format!("{}{:>8}{}", &line[..48], xf, &line[56..]),
|
||||
None => line.to_string(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimmed_surfaces_sew_into_a_solid() {
|
||||
let c =
|
||||
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
|
||||
fn groups_and_subfigure_instances_keep_names_structure_and_placements() {
|
||||
for (form, scale) in [(1, 1.0), (7, 2.0), (14, 1.0), (15, 2.0)] {
|
||||
let imp = iges_import(&assembly_file(form, scale)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert_eq!(imp.bodies.len(), 2);
|
||||
assert_eq!(imp.tree.len(), 1);
|
||||
let motion = &imp.tree[0].children[0];
|
||||
assert_eq!(motion.name, "Motion");
|
||||
let frame = &motion.children[0];
|
||||
assert_eq!(frame.name, "Frame");
|
||||
assert_eq!(frame.children.len(), 2);
|
||||
assert!(frame.children.iter().all(|n| n.name == "Lead rod"));
|
||||
assert!(imp.bodies.iter().all(|b| b.path == ["Import", "Motion", "Frame", "Lead rod"]));
|
||||
assert_eq!(imp.bodies[0].source_item, imp.bodies[1].source_item);
|
||||
let a = measure(&imp.bodies[0].body).unwrap();
|
||||
let b = measure(&imp.bodies[1].body).unwrap();
|
||||
assert!(rel(a.volume, 24.0) < 1e-6 && rel(b.volume, 24.0 * scale.powi(3)) < 1e-6);
|
||||
assert!(a.bbox.min.dist(Vec3::new(10.0, 0.0, 0.0)) < 1e-6 && a.bbox.max.dist(Vec3::new(12.0, 3.0, 4.0)) < 1e-6);
|
||||
assert!(b.bbox.min.dist(Vec3::new(80.0 - 3.0 * scale, 0.0, 0.0)) < 1e-6);
|
||||
assert!(b.bbox.max.dist(Vec3::new(80.0, 2.0 * scale, 4.0 * scale)) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_assembly_references_and_counts_fail_before_geometry() {
|
||||
for (params, message) in [
|
||||
("0,4HLoop,1,1".into(), "cyclic"),
|
||||
("0,4HPart,1,999".into(), "missing entity"),
|
||||
("0,4HPart,-1".into(), "negative IGES group size"),
|
||||
(format!("0,4HPart,{}", MAX_LIST + 1), "work limit"),
|
||||
] {
|
||||
let error = iges_import(&iges_file(&[(308, 0, params)])).unwrap_err();
|
||||
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains(message)), "{error}");
|
||||
}
|
||||
let mut chain = Vec::new();
|
||||
for k in 0..=MAX_DEPTH {
|
||||
chain.push((308, 0, if k == MAX_DEPTH { "0,4HPart,0".into() } else { format!("0,4HPart,1,{}", 2 * k + 3) }));
|
||||
}
|
||||
let error = iges_import(&iges_file(&chain)).unwrap_err();
|
||||
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("nesting is too deep")), "{error}");
|
||||
let members = "1,".repeat(100_000);
|
||||
let many = [(308, 0, "0,4HPart,0".into()), (308, 0, format!("1,5HFrame,100001,{members}1"))];
|
||||
let error = iges_import(&iges_file(&many)).unwrap_err();
|
||||
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("too many IGES assembly placements")), "{error}");
|
||||
}
|
||||
|
||||
/// Six trimmed bilinear patches with line boundaries, forming a 10 × 20 × 30 box.
|
||||
fn trimmed_box(ents: &mut Vec<(i64, i64, String)>, x: f64) -> Vec<usize> {
|
||||
let c = |i: usize| -> [f64; 3] {
|
||||
[x + if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }]
|
||||
};
|
||||
// Faces as corner quads (counter-clockwise seen from outside).
|
||||
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
|
||||
let mut ents: Vec<(i64, i64, String)> = Vec::new();
|
||||
let mut faces = Vec::new();
|
||||
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
|
||||
for q in quads {
|
||||
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
|
||||
@@ -182,7 +283,15 @@ fn trimmed_surfaces_sew_into_a_solid() {
|
||||
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
|
||||
let cos = 2 * ents.len() - 1;
|
||||
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
|
||||
faces.push(2 * ents.len() - 1);
|
||||
}
|
||||
faces
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimmed_surfaces_sew_into_a_solid() {
|
||||
let mut ents = Vec::new();
|
||||
trimmed_box(&mut ents, 0.0);
|
||||
let imp = iges_import(&iges_file(&ents)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert!(imp.bodies[0].closed);
|
||||
@@ -190,6 +299,49 @@ fn trimmed_surfaces_sew_into_a_solid() {
|
||||
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
|
||||
}
|
||||
|
||||
/// Trim pieces whose ends miss each other by a hundredth of a millimetre, far past the file's
|
||||
/// stated resolution: the loop is closed and the box sews into a solid. A gap of half a
|
||||
/// millimetre is no corner written loosely, and stays open.
|
||||
#[test]
|
||||
fn trim_loops_with_small_gaps_still_close() {
|
||||
let with_gap = |gap: f64| {
|
||||
let mut ents = Vec::new();
|
||||
trimmed_box(&mut ents, 0.0);
|
||||
// The edge from (0, 0, 0) towards (0, 20, 0) stops `gap` short of that corner, in both
|
||||
// faces that run along it (the first face's first line, the fifth face's last).
|
||||
ents[1].2 = format!("0.,0.,0.,0.,{:?},0.", 20.0 - gap);
|
||||
ents[36].2 = format!("0.,{:?},0.,0.,0.,0.", 20.0 - gap);
|
||||
iges_import(&iges_file(&ents))
|
||||
};
|
||||
let imp = with_gap(0.01).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert!(imp.bodies[0].closed);
|
||||
let m = measure(&imp.bodies[0].body).unwrap();
|
||||
assert!(rel(m.volume, 6000.0) < 1e-3 && rel(m.area, 2200.0) < 1e-3, "{} {}", m.volume, m.area);
|
||||
if let Ok(imp) = with_gap(0.5) {
|
||||
assert!(imp.bodies.iter().all(|b| !b.closed), "a 0.5 mm gap was closed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surfaces_in_separate_groups_remain_separate_parts() {
|
||||
let mut ents = Vec::new();
|
||||
for (name, x) in [("Left", 0.0), ("Right", 100.0)] {
|
||||
let faces = trimmed_box(&mut ents, x);
|
||||
let members = faces.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
|
||||
ents.push((308, 0, format!("0,{}H{name},{},{members}", name.len(), faces.len())));
|
||||
}
|
||||
let imp = iges_import(&iges_file(&ents)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
|
||||
assert_eq!(imp.bodies.len(), 2);
|
||||
assert_eq!(imp.tree[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Left", "Right"]);
|
||||
for (body, x) in imp.bodies.iter().zip([0.0, 100.0]) {
|
||||
let m = measure(&body.body).unwrap();
|
||||
assert!(body.closed && rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6);
|
||||
assert!(m.bbox.min.dist(Vec3::new(x, 0.0, 0.0)) < 1e-6 && m.bbox.max.dist(Vec3::new(x + 10.0, 20.0, 30.0)) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drill_point_round_trips_through_iges() {
|
||||
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
|
||||
@@ -342,3 +494,42 @@ fn a_hole_whose_first_trim_piece_is_reversed_stays_a_hole() {
|
||||
let m = measure(&imp.bodies[0].body).unwrap();
|
||||
assert!(rel(m.area, 84.0) < 1e-6, "area {}", m.area);
|
||||
}
|
||||
|
||||
/// The box of `trimmed_surfaces_sew_into_a_solid` with one edge written as a B-spline (126)
|
||||
/// over a knot span of 1e-7 or less, as some exporters write short curves: the STEP side read
|
||||
/// that span as a single value and refused the curve, and both faces it bounds were lost.
|
||||
#[test]
|
||||
fn a_curve_over_a_tiny_knot_span_still_bounds_its_faces() {
|
||||
let c =
|
||||
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
|
||||
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
|
||||
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
|
||||
for span in [1e-7f64, 1e-9, 1e-13] {
|
||||
let mut ents: Vec<(i64, i64, String)> = Vec::new();
|
||||
for (fi, q) in quads.iter().enumerate() {
|
||||
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
|
||||
let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc));
|
||||
ents.push((128, 0, surf));
|
||||
let s_de = 2 * ents.len() - 1;
|
||||
let mut lines = Vec::new();
|
||||
for (k, (p0, p1)) in [(a, b), (b, cc), (cc, d), (d, a)].into_iter().enumerate() {
|
||||
if fi == 0 && k == 0 {
|
||||
ents.push((126, 0, format!("1,1,0,0,1,0,0.,0.,{span:E},{span:E},1.,1.,{},{},0.,{span:E},0.,0.,1.", fmt(p0), fmt(p1))));
|
||||
} else {
|
||||
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
|
||||
}
|
||||
lines.push(2 * ents.len() - 1);
|
||||
}
|
||||
ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(","))));
|
||||
let comp = 2 * ents.len() - 1;
|
||||
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
|
||||
let cos = 2 * ents.len() - 1;
|
||||
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
|
||||
}
|
||||
let imp = iges_import(&iges_file(&ents)).unwrap();
|
||||
assert!(imp.warnings.is_empty(), "span {span}: {:?}", imp.warnings);
|
||||
assert!(imp.bodies[0].closed, "span {span}");
|
||||
let m = measure(&imp.bodies[0].body).unwrap();
|
||||
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "span {span}: {} {}", m.volume, m.area);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ mod tspline;
|
||||
#[cfg(test)]
|
||||
mod untouched_faces_tests;
|
||||
pub mod watch;
|
||||
mod whole_face;
|
||||
|
||||
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
|
||||
pub use blend_corner::fillet_radii;
|
||||
@@ -220,6 +221,7 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_
|
||||
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum KernelError {
|
||||
|
||||
@@ -22,15 +22,21 @@ pub struct BodyMeasure {
|
||||
pub merged: TopoCounts,
|
||||
}
|
||||
|
||||
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem, origin 0).
|
||||
fn face_sums(m: &Mesh, nf: usize) -> Vec<(f64, f64, Vec3)> {
|
||||
let mut out = vec![(0.0, 0.0, Vec3::ZERO); nf];
|
||||
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem), taken
|
||||
/// about `o` (the moments relative to it), and the sum of the area vectors (zero over a closed
|
||||
/// surface). Over a closed surface the volume does not depend on `o`; over one that leaves a
|
||||
/// gap, the gap is closed by a cone to `o`, so `o` near the body keeps that cone small.
|
||||
fn face_sums(m: &Mesh, nf: usize, o: Vec3) -> Vec<(f64, f64, Vec3, Vec3)> {
|
||||
let mut out = vec![(0.0, 0.0, Vec3::ZERO, Vec3::ZERO); nf];
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let (Some([a, b, c]), Some(slot)) = (m.tri(t), out.get_mut(*f as usize)) else { continue };
|
||||
let n = (b - a).cross(c - a) * 0.5;
|
||||
let (a, b, c) = (a - o, b - o, c - o);
|
||||
let d = a.dot(b.cross(c));
|
||||
slot.0 += (b - a).cross(c - a).len() * 0.5;
|
||||
slot.0 += n.len();
|
||||
slot.1 += d;
|
||||
slot.2 += (a + b + c) * d;
|
||||
slot.3 += n;
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -152,7 +158,13 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
|
||||
None => None,
|
||||
};
|
||||
let nf = b.face_count();
|
||||
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
|
||||
// About the box's centre when the meshes leave only a crack: see `face_sums`. A body that is
|
||||
// open on purpose (a surface, or a cup whose lid is still a separate sheet) has no volume of
|
||||
// its own; it keeps the origin, so such bodies still compare with each other when the centre
|
||||
// of their boxes moves (a crowned lid rises, the open cup under it doesn't change).
|
||||
let (surface, gap) = face_sums(&fine, nf, Vec3::ZERO).iter().fold((0.0, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.3));
|
||||
let o = Some(fine.bounds().center()).filter(|c| c.is_finite() && gap.len() <= 0.01 * surface).unwrap_or(Vec3::ZERO);
|
||||
let (sf, sm, sc) = (face_sums(&fine, nf, o), face_sums(&medium, nf, o), face_sums(&coarse, nf, o));
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
|
||||
let (mut area, mut v6, mut mom) = (0.0, 0.0, Vec3::ZERO);
|
||||
for ((f, m), c) in sf.iter().zip(&sm).zip(&sc) {
|
||||
@@ -168,6 +180,13 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
|
||||
v6 += pick.1;
|
||||
mom += pick.2;
|
||||
}
|
||||
// The moments back about the origin. A gap the meshes leave (a crack where a trimmed face
|
||||
// meets a hole cut into it) is closed by a cone to the box's centre, which keeps it small
|
||||
// (a crack of 0.3 mm² moved a part ring's volume by 7 % about the origin 1000 mm away; about
|
||||
// the centre, by 0.1 %). Every body is measured by the same meshes this way, so a body and the
|
||||
// pieces a boolean cuts it into add up: a closed display plan measured instead would mesh
|
||||
// some faces more coarsely than the sums do and break that.
|
||||
let mom = o * (4.0 * v6) + mom;
|
||||
let volume = v6 / 6.0;
|
||||
let centroid = if v6.abs() > 1e-12 { mom / (4.0 * v6) } else { fine.bounds().center() };
|
||||
Ok((MassMeasure { volume, area, centroid, bbox: coarse.bounds().union(&fine.bounds()) }, topo))
|
||||
@@ -197,4 +216,121 @@ mod tests {
|
||||
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}");
|
||||
assert!(measure(&ring).unwrap().merged.faces > 0);
|
||||
}
|
||||
|
||||
/// A 10 mm cube at `at`, each side in n × n squares of two triangles, one face per side;
|
||||
/// without its first `open` triangles.
|
||||
fn cube(at: Vec3, n: usize, open: usize) -> Mesh {
|
||||
let mut m = Mesh::default();
|
||||
let s = 10.0;
|
||||
for axis in 0..3 {
|
||||
for side in [0.0, 1.0] {
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let corner = |a: usize, b: usize| {
|
||||
let mut c = [0.0; 3];
|
||||
c[axis] = side * s;
|
||||
c[(axis + 1) % 3] = a as f64 * s / n as f64;
|
||||
c[(axis + 2) % 3] = b as f64 * s / n as f64;
|
||||
at + Vec3::new(c[0], c[1], c[2])
|
||||
};
|
||||
let k = m.positions.len() as u32;
|
||||
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
|
||||
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
|
||||
m.tri_face.extend([(2 * axis) as u32 + side as u32; 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.triangles.drain(..open);
|
||||
m.tri_face.drain(..open);
|
||||
m
|
||||
}
|
||||
|
||||
/// The summed per-face sums of `m` about `o`, with the moments about the origin (as
|
||||
/// `measure` passes them on).
|
||||
fn sums(m: &Mesh, o: Vec3) -> (f64, f64, Vec3, Vec3) {
|
||||
let (area, v6, mom, normal) =
|
||||
face_sums(m, 6, o).iter().fold((0.0, 0.0, Vec3::ZERO, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.1, s.2 + f.2, s.3 + f.3));
|
||||
(area, v6, o * (4.0 * v6) + mom, normal)
|
||||
}
|
||||
|
||||
/// A cube 1000 mm from the origin with one 0.5 mm² facet left out: about the box's centre
|
||||
/// the sums miss a cone of that facet to the centre (0.08 %), where about the origin they are
|
||||
/// 17 % over. Closed, it measures the same about either.
|
||||
#[test]
|
||||
fn a_mesh_left_open_is_measured_about_its_centre() {
|
||||
let at = Vec3::new(1000.0, 1000.0, 1000.0);
|
||||
let (closed, open) = (cube(at, 10, 0), cube(at, 10, 1));
|
||||
let centre = at + Vec3::new(5.0, 5.0, 5.0);
|
||||
for o in [Vec3::ZERO, centre] {
|
||||
let (area, v6, mom, normal) = sums(&closed, o);
|
||||
assert!((area - 600.0).abs() < 1e-9 && (v6 / 6.0 - 1000.0).abs() < 1e-6 && normal.len() < 1e-9);
|
||||
assert!((mom / (4.0 * v6) - centre).len() < 1e-6 * at.len(), "{o:?}");
|
||||
}
|
||||
let (_, v6_origin, _, normal) = sums(&open, Vec3::ZERO);
|
||||
assert!((normal.len() - 0.5).abs() < 1e-9);
|
||||
// The facet faces the origin: leaving it out adds its cone to the origin, 167 mm³.
|
||||
assert!((v6_origin / 6.0 - (1000.0 + 0.5 * 1000.0 / 3.0)).abs() < 1e-3, "{}", v6_origin / 6.0);
|
||||
let (_, v6, mom, _) = sums(&open, centre);
|
||||
assert!((v6 / 6.0 - (1000.0 - 0.5 * 5.0 / 3.0)).abs() < 1e-6, "{}", v6 / 6.0);
|
||||
assert!((mom / (4.0 * v6) - centre).len() < 0.01, "{:?}", mom / (4.0 * v6));
|
||||
}
|
||||
|
||||
// A body's measured volume does not depend on where it sits: the same revolved ring of cones
|
||||
// and cylinders, moved far from the origin, measures what it measures at the origin.
|
||||
use crate::{revolve, transform};
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
/// Ring cross-section in (radius, height): cones top and bottom, cylinders inside and out.
|
||||
const PROFILE: [(f64, f64); 6] = [(5.0, 0.0), (9.0, 0.0), (11.0, 2.0), (11.0, 6.0), (8.0, 8.0), (5.0, 8.0)];
|
||||
|
||||
/// Pappus: 2π × the section's first moment about the axis.
|
||||
fn ring_volume() -> f64 {
|
||||
let mut moment6 = 0.0;
|
||||
for (i, &(x0, y0)) in PROFILE.iter().enumerate() {
|
||||
let (x1, y1) = PROFILE[(i + 1) % PROFILE.len()];
|
||||
moment6 += (x0 + x1) * (x0 * y1 - x1 * y0);
|
||||
}
|
||||
TAU * moment6 / 6.0
|
||||
}
|
||||
|
||||
fn revolved(plane: &Plane, pts: &[Vec2]) -> Body {
|
||||
let region = Region2 { outer: Loop2::polygon(pts), holes: vec![] };
|
||||
revolve(plane, &[region], Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_volume_does_not_depend_on_placement() {
|
||||
let pts: Vec<Vec2> = PROFILE.iter().map(|&(r, z)| Vec2::new(r, z)).collect();
|
||||
let base = revolved(&Plane::XZ, &pts);
|
||||
let at_origin = measure(&base).unwrap();
|
||||
assert!(rel(at_origin.volume, ring_volume()) < 1e-4, "{} vs {}", at_origin.volume, ring_volume());
|
||||
for d in [100.0, 1000.0, 10000.0] {
|
||||
for axis in [Vec3::X, Vec3::Y, Vec3::Z, Vec3::new(1.0, 1.0, 1.0)] {
|
||||
let m = measure(&transform(&base, axis * d, Vec3::ZERO, Vec3::Z, 0.0).unwrap()).unwrap();
|
||||
assert!(rel(m.volume, at_origin.volume) < 1e-6, "moved {d} along {axis:?}: {} vs {}", m.volume, at_origin.volume);
|
||||
let off = m.centroid - at_origin.centroid - axis * d;
|
||||
assert!(off.len() < 1e-6 * d, "centroid moved {d} along {axis:?}: off by {off:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_and_cone_volumes_far_from_the_origin() {
|
||||
let (r, h) = (7.0, 12.0);
|
||||
// A frustum, radii 7 and 3, height 10: πh/3 (R² + Rr + r²).
|
||||
let frustum = [Vec2::new(0.0, 0.0), Vec2::new(7.0, 0.0), Vec2::new(3.0, 10.0), Vec2::new(0.0, 10.0)];
|
||||
let cone_volume = PI * 10.0 / 3.0 * (49.0 + 21.0 + 9.0);
|
||||
for at in [Vec3::ZERO, Vec3::new(5000.0, -3000.0, 8000.0)] {
|
||||
let cyl = measure(&cylinder(at, Vec3::Z, r, h).unwrap()).unwrap();
|
||||
assert!(rel(cyl.volume, PI * r * r * h) < 5e-4, "cylinder at {at:?}: {}", cyl.volume);
|
||||
let cone = measure(&revolved(&Plane { origin: at, ..Plane::XZ }, &frustum)).unwrap();
|
||||
assert!(rel(cone.volume, cone_volume) < 5e-4, "frustum at {at:?}: {} vs {cone_volume}", cone.volume);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
Ok(Body {
|
||||
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
|
||||
size: 0.0,
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
@@ -203,6 +204,7 @@ impl Body {
|
||||
Some(Body {
|
||||
solid: self.solid.clone(),
|
||||
size: 0.0,
|
||||
boundary_tol: None,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: Some(Arc::new(out)),
|
||||
|
||||
@@ -37,6 +37,10 @@ struct Runtime {
|
||||
size: f64,
|
||||
/// The orientation mesh's missing-face count at its tolerance.
|
||||
coarse_missing: Option<(f64, u64)>,
|
||||
/// [`Body::boundary_tol`]: the accuracy the file it was read from declares. Written only
|
||||
/// when there is one, so a body without it is stored as before.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
boundary_tol: Option<f64>,
|
||||
}
|
||||
fn invalid(detail: impl std::fmt::Display) -> KernelError {
|
||||
KernelError::Invalid(format!("native B-rep: {detail}"))
|
||||
@@ -66,7 +70,8 @@ pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
|
||||
if body.mesh.is_some() {
|
||||
return Err(invalid("mesh bodies are source blobs, not B-rep"));
|
||||
}
|
||||
let runtime = Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)) };
|
||||
let runtime =
|
||||
Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)), boundary_tol: body.boundary_tol };
|
||||
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)?, runtime };
|
||||
// Structural validation does not invoke another geometric reconstruction.
|
||||
validate_topology(&saved)?;
|
||||
@@ -98,13 +103,17 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
|
||||
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
|
||||
validate_topology(&saved)?;
|
||||
let solid = adapter::decode(saved.solid)?;
|
||||
let Runtime { size, coarse_missing } = saved.runtime;
|
||||
if !(size.is_finite() && size >= 0.0) || coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64) {
|
||||
let Runtime { size, coarse_missing, boundary_tol } = saved.runtime;
|
||||
if !(size.is_finite() && size >= 0.0)
|
||||
|| coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64)
|
||||
|| boundary_tol.is_some_and(|t| !(t.is_finite() && t > 0.0))
|
||||
{
|
||||
return Err(invalid("runtime values"));
|
||||
}
|
||||
let mut body = Body {
|
||||
solid: Arc::new(solid),
|
||||
size,
|
||||
boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: coarse_missing.map(|(tol, n)| (tol, n as usize)),
|
||||
mesh: None,
|
||||
@@ -228,6 +237,18 @@ mod tests {
|
||||
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
|
||||
}
|
||||
}
|
||||
/// A body read from a file keeps the accuracy the file declares through the model cache, so
|
||||
/// a reopened design plans its faces as the import did; one without it is stored as before.
|
||||
#[test]
|
||||
fn a_files_declared_accuracy_survives_the_model_cache() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
|
||||
let plain = encode_native_body(&cube).unwrap();
|
||||
assert_eq!(decode_native_body(&plain).unwrap().boundary_tol, None);
|
||||
let read = cube.with_boundary_tol(Some(1e-5));
|
||||
let bytes = encode_native_body(&read).unwrap();
|
||||
assert_ne!(bytes, plain);
|
||||
assert_eq!(decode_native_body(&bytes).unwrap().boundary_tol, Some(1e-5));
|
||||
}
|
||||
#[test]
|
||||
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
|
||||
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
|
||||
|
||||
@@ -85,9 +85,13 @@ impl NumberedLoop {
|
||||
NumberedLoop { lp: pieces, from }
|
||||
}
|
||||
|
||||
/// Re-split into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
|
||||
/// other loops split their longest segments in half.
|
||||
pub(crate) fn resample(&self, n: usize, angles: &[f64]) -> NumberedLoop {
|
||||
/// Re-split into exactly `n` segments, other loops split their longest segments in half. A
|
||||
/// full circle is split once round (one turn, every arc a positive sweep) to follow a lead
|
||||
/// loop: at the lead's vertex directions `angles` when they go round in order (counter-
|
||||
/// clockwise, or clockwise when `clockwise`), otherwise from the first of them at the lead's
|
||||
/// perimeter `fractions` (the share of the lead's length before each vertex; evenly when
|
||||
/// those are unusable).
|
||||
pub(crate) fn resample(&self, n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> NumberedLoop {
|
||||
let lp = &self.lp;
|
||||
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
|
||||
&& lp.segs.windows(2).all(|w| match (w.first(), w.get(1)) {
|
||||
@@ -96,22 +100,15 @@ impl NumberedLoop {
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
|
||||
let mut a: Vec<f64> =
|
||||
if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
|
||||
// Keep the given order but make them increase.
|
||||
for i in 1..a.len() {
|
||||
let Some(prev) = a.get(i - 1).copied() else { continue };
|
||||
if let Some(x) = a.get_mut(i) {
|
||||
while *x <= prev {
|
||||
*x += std::f64::consts::TAU;
|
||||
}
|
||||
}
|
||||
}
|
||||
let segs: Vec<Seg2> = (0..n)
|
||||
if circle
|
||||
&& n > 0
|
||||
&& let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied()
|
||||
{
|
||||
let a = circle_breaks(n, angles, fractions, clockwise);
|
||||
let segs: Vec<Seg2> = (0..a.len())
|
||||
.filter_map(|i| {
|
||||
let s = *a.get(i)?;
|
||||
let e = if i + 1 < n { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
|
||||
let e = if i + 1 < a.len() { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
|
||||
Some(Seg2::Arc { center, radius, start: s, sweep: e - s })
|
||||
})
|
||||
.collect();
|
||||
@@ -190,6 +187,65 @@ impl NumberedRegion {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest arc a circle is split into (radians).
|
||||
const MIN_SWEEP: f64 = 1e-9;
|
||||
|
||||
/// `n` increasing break angles of one turn round a circle (each step and the closing step back
|
||||
/// to the first, one turn on, positive) for a circle following a lead loop: `angles` (the lead's
|
||||
/// vertex directions) when they go round once in order in the lead's direction (counter-clockwise,
|
||||
/// or clockwise when `clockwise`), otherwise from the first of them at the lead's perimeter
|
||||
/// `fractions`, or evenly. A non-star-shaped lead's directions turn back on themselves: forcing
|
||||
/// them to increase wound the circle round many times (#2570).
|
||||
fn circle_breaks(n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> Vec<f64> {
|
||||
use std::f64::consts::TAU;
|
||||
let sign = if clockwise { -1.0 } else { 1.0 };
|
||||
// Angles measured in the lead's direction of travel.
|
||||
let along: Vec<f64> = angles.iter().map(|a| sign * a).collect();
|
||||
let run = (along.len() == n).then(|| one_turn(&along)).flatten().unwrap_or_else(|| {
|
||||
let start = along.first().copied().filter(|a| a.is_finite()).unwrap_or(0.0);
|
||||
let even = || (0..n).map(|i| i as f64 / n as f64).collect::<Vec<f64>>();
|
||||
let f = if usable_fractions(n, fractions) { fractions.to_vec() } else { even() };
|
||||
f.iter().map(|f| start + TAU * f).collect()
|
||||
});
|
||||
let mut breaks: Vec<f64> = run.into_iter().map(|a| sign * a).collect();
|
||||
if clockwise {
|
||||
breaks.reverse();
|
||||
}
|
||||
breaks
|
||||
}
|
||||
|
||||
/// `along` unwrapped into one increasing turn (each step the shortest positive one), or `None`
|
||||
/// when they do not go round once in order.
|
||||
fn one_turn(along: &[f64]) -> Option<Vec<f64>> {
|
||||
let mut out: Vec<f64> = Vec::with_capacity(along.len());
|
||||
for &x in along {
|
||||
if !x.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let next = match out.last() {
|
||||
Some(&prev) => {
|
||||
let step = (x - prev).rem_euclid(std::f64::consts::TAU);
|
||||
if step <= MIN_SWEEP {
|
||||
return None;
|
||||
}
|
||||
prev + step
|
||||
}
|
||||
None => x,
|
||||
};
|
||||
out.push(next);
|
||||
}
|
||||
let (first, last) = (*out.first()?, *out.last()?);
|
||||
(last - first < std::f64::consts::TAU - MIN_SWEEP).then_some(out)
|
||||
}
|
||||
|
||||
/// `fractions` are `n` finite, strictly increasing shares of a turn from 0, all under one.
|
||||
fn usable_fractions(n: usize, fractions: &[f64]) -> bool {
|
||||
fractions.len() == n
|
||||
&& fractions.first().is_some_and(|f| f.abs() < MIN_SWEEP)
|
||||
&& fractions.windows(2).all(|w| matches!(w, [a, b] if b - a > MIN_SWEEP))
|
||||
&& fractions.last().is_some_and(|f| f.is_finite() && *f < 1.0 - MIN_SWEEP)
|
||||
}
|
||||
|
||||
/// Every number of `sets`, once each, in order.
|
||||
fn union<'a>(sets: impl IntoIterator<Item = &'a [usize]>) -> Vec<usize> {
|
||||
sets.into_iter().flatten().copied().collect::<BTreeSet<usize>>().into_iter().collect()
|
||||
|
||||
@@ -2126,6 +2126,15 @@ fn transform_matrix_keeping(body: &Body, m: [[f64; 4]; 4], phased: bool) -> Resu
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
}
|
||||
// A perspective row or a flattening (from a design file or a command's parameters) leaves
|
||||
// no solid: refused rather than built into faces that mesh to nonsense.
|
||||
let scale = (0..3).map(|c| Vec3::new(m[c][0], m[c][1], m[c][2]).len()).fold(0.0, f64::max);
|
||||
if m[0][3].abs() > 1e-12 || m[1][3].abs() > 1e-12 || m[2][3].abs() > 1e-12 || (m[3][3] - 1.0).abs() > 1e-12 {
|
||||
return Err(KernelError::Invalid("the transform is not affine: its last row must be 0, 0, 0, 1".into()));
|
||||
}
|
||||
if det3(&m).abs() <= 1e-12 * scale.powi(3) {
|
||||
return Err(KernelError::Invalid("the transform flattens the body: its linear part is singular".into()));
|
||||
}
|
||||
if let Some(placed) = body.mesh_transformed(&m) {
|
||||
let history = crate::history::History::identity(&placed);
|
||||
return placed.with_history(history, &[body]);
|
||||
|
||||
@@ -503,6 +503,7 @@ fn divided(body: &Body, faces: &[usize], other: &[(mt::Edge, Piece)], fr: &Frame
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
boundary_tol: body.boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
|
||||
@@ -796,7 +796,7 @@ fn unsupported_shell(m: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {m}"))
|
||||
}
|
||||
|
||||
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
pub(crate) fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
|
||||
let n = (b - a).cross(c - a);
|
||||
let Some(nn) = n.normalized() else { return f64::INFINITY };
|
||||
let h = (p - a).dot(nn);
|
||||
|
||||
@@ -636,6 +636,7 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
boundary_tol: body.boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
@@ -1173,6 +1174,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
|
||||
let split = Body {
|
||||
solid: std::sync::Arc::new(solid),
|
||||
size: 0.0,
|
||||
boundary_tol: body.boundary_tol,
|
||||
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
|
||||
coarse_missing: None,
|
||||
mesh: None,
|
||||
|
||||
@@ -53,6 +53,63 @@ fn rid(p: Option<&Param>, what: &str) -> R<u64> {
|
||||
/// A contractible outer loop whose winding opposes the surface's parameter normal.
|
||||
/// Samples follow the wire through periodic seams; a loop that winds around a surface is
|
||||
/// not a disk in parameter space and must retain the file's sense.
|
||||
/// The point of a cone (a surface revolved from a line that meets its axis) when `ring`, a
|
||||
/// face's only loop, goes once round the axis without touching it: the face then reaches the
|
||||
/// point. `None` for any other surface or loop.
|
||||
fn enclosed_apex(surface: &mt::Surface, ring: &mt::Wire, tol: f64) -> Option<mt::Point3> {
|
||||
use mt::Transform as _;
|
||||
let mt::Surface::RevolutedCurve(proc) = surface else { return None };
|
||||
let rc = proc.entity();
|
||||
let mt::Curve::Line(line) = rc.entity_curve() else { return None };
|
||||
let (o, axis) = (rc.origin(), rc.axis().normalize());
|
||||
// The rotation applied to the revolved surface, applied to its axis and profile too.
|
||||
let m = proc.transform();
|
||||
let (o, axis) = (m.transform_point(o), m.transform_vector(axis).normalize());
|
||||
let (p0, p1) = (m.transform_point(line.0), m.transform_point(line.1));
|
||||
let radius = |p: mt::Point3| (p - o).cross(axis).magnitude();
|
||||
let (r0, r1) = (radius(p0), radius(p1));
|
||||
if !((r0 - r1).abs() > 1e-9 * (r0 + r1)) {
|
||||
return None;
|
||||
}
|
||||
let apex = p0 + (p1 - p0) * (r0 / (r0 - r1));
|
||||
if !(radius(apex) <= tol.max(1e-9) * 10.0) {
|
||||
return None;
|
||||
}
|
||||
// Once round the axis, staying clear of it.
|
||||
let x = (p0 - o) - axis * (p0 - o).dot(axis);
|
||||
let x = if x.magnitude() > tol { x.normalize() } else { return None };
|
||||
let y = axis.cross(x);
|
||||
let mut turned = 0.0;
|
||||
let mut last: Option<f64> = None;
|
||||
let mut first: Option<f64> = None;
|
||||
for edge in ring.edge_iter() {
|
||||
let c = edge.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..16 {
|
||||
let q = c.subs(t0 + (t1 - t0) * f64::from(k) / 16.0);
|
||||
if radius(q) <= tol {
|
||||
return None;
|
||||
}
|
||||
let d = q - o;
|
||||
let ang = d.dot(y).atan2(d.dot(x));
|
||||
if let Some(l) = last {
|
||||
let mut step = ang - l;
|
||||
step -= (step / std::f64::consts::TAU).round() * std::f64::consts::TAU;
|
||||
turned += step;
|
||||
} else {
|
||||
first = Some(ang);
|
||||
}
|
||||
last = Some(ang);
|
||||
}
|
||||
}
|
||||
if let (Some(l), Some(f)) = (last, first) {
|
||||
let mut step = f - l;
|
||||
step -= (step / std::f64::consts::TAU).round() * std::f64::consts::TAU;
|
||||
turned += step;
|
||||
}
|
||||
((turned.abs() - std::f64::consts::TAU).abs() < 0.1).then_some(apex)
|
||||
}
|
||||
|
||||
fn reversed_outer(surface: &mt::Surface, wire: &mt::Wire, tol: f64) -> bool {
|
||||
if let mt::Surface::RevolutedCurve(proc) = surface {
|
||||
let rc = proc.entity();
|
||||
@@ -137,8 +194,9 @@ impl Builder {
|
||||
let sense = p.get(4).and_then(Param::as_bool).unwrap_or(true);
|
||||
let g = geom::curve(cx, rid(p.get(3), "edge geometry")?, 0)?;
|
||||
let closed = vs_id == ve_id;
|
||||
let pieces = guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let pieces =
|
||||
guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol, cx.snap).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let n = pieces.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut cur = vs.clone();
|
||||
@@ -321,6 +379,35 @@ impl Builder {
|
||||
surface = guard("step surface orientation", || geom::surface(cx, sid, &extent, &seam, !sense, 0).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// A cone face bounded only by a ring round its axis (no vertex at the point, or a
|
||||
// VERTEX_LOOP there, which has no edges) covers the cone up to its point. Its surface
|
||||
// was spanned over the ring alone, a sliver at the base; it is spanned to the apex,
|
||||
// and the ring is joined to the apex by a seam line out and back, as a cone with a
|
||||
// seam is written.
|
||||
let (mut wires, mut surface) = (wires, surface);
|
||||
if e.name() != "FACE"
|
||||
&& seam.is_empty()
|
||||
&& let [ring] = wires.as_slice()
|
||||
&& let Some(apex) = enclosed_apex(&surface, ring, cx.tol)
|
||||
&& let Some(first) = ring.edge_iter().next()
|
||||
{
|
||||
let v = first.front().clone();
|
||||
let a = mt::Vertex::new(apex);
|
||||
let line = mt::Edge::new_unchecked(&v, &a, mt::Curve::Line(mt::Line(v.point(), apex)));
|
||||
let mut to_apex = extent.clone();
|
||||
to_apex.push(apex);
|
||||
let seam_line = [v.point(), apex];
|
||||
let sid = rid(p.get(2), "face geometry")?;
|
||||
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
|
||||
let spanned =
|
||||
guard("step cone to its point", || geom::surface(cx, sid, &to_apex, &seam_line, sense, 0).map_err(crate::KernelError::Failed))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut edges: Vec<mt::Edge> = ring.edge_iter().cloned().collect();
|
||||
edges.push(line.clone());
|
||||
edges.push(line.inverse());
|
||||
wires = vec![edges.into()];
|
||||
surface = spanned;
|
||||
}
|
||||
// Loops on a surface of revolution start away from the axis: a pole (cone apex, sphere
|
||||
// pole) has no definite rotation parameter, and the mesher takes the first point's
|
||||
// parameter as the anchor for the rest of the loop.
|
||||
@@ -417,7 +504,7 @@ impl Builder {
|
||||
origin = vec![file_face; patches.len()];
|
||||
out = patches;
|
||||
}
|
||||
let out = super::split::split_bands(out, &mut origin, failed);
|
||||
let out = super::split::split_bands(out, &mut origin, failed, cx.tol);
|
||||
Ok((out.into(), closed, read, origin))
|
||||
}
|
||||
}
|
||||
@@ -555,5 +642,22 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
|
||||
if checked.is_err() {
|
||||
closed = false;
|
||||
}
|
||||
// A void's faces face into the cavity, out of the material: it encloses volume of the
|
||||
// opposite sign to the outer shell. The ORIENTED_CLOSED_SHELL flag alone doesn't say so:
|
||||
// some exporters write .F. over a shell already facing into the cavity (#3730), others .T.
|
||||
// A void enclosing volume of the outer shell's sign is turned inside out.
|
||||
if e.name() == "BREP_WITH_VOIDS"
|
||||
&& checked.is_ok()
|
||||
&& let Some((outer, voids)) = shells.split_first_mut()
|
||||
&& let Some(outside) = crate::body::shell_volume(outer)
|
||||
{
|
||||
for void in voids {
|
||||
if crate::body::shell_volume(void).is_some_and(|v| v * outside > 0.0) {
|
||||
for face in void.face_iter_mut() {
|
||||
face.invert();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(BrepOut { solid: mt::Solid::new_unchecked(shells), failed, closed, file_faces, face_origin })
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn extruded_ellipse_seam_uses_profile_angles_for_oblique_sweeps() {
|
||||
{TAIL}"
|
||||
);
|
||||
let ex = p21::parse(&text).unwrap();
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
|
||||
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
|
||||
let origin = mt::Point3::new(10.0, -20.0, 3.0);
|
||||
let d = mt::Vector3::new(0.5, 0.25, 1.0).normalize();
|
||||
let point = |angle: f64, h: f64| origin + mt::Vector3::new(9.0 * angle.cos(), 4.0 * angle.sin(), 0.0) + d * h;
|
||||
@@ -236,3 +236,40 @@ fn hostile_conics_never_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cone to a point written with no vertex at its point (#258): the cone face's only loop is
|
||||
/// its base circle, or that circle and a VERTEX_LOOP at the point, which has no edges. It was
|
||||
/// read as a sliver at the base and the solid came in as a flat disc; the face reaches the
|
||||
/// point now.
|
||||
#[test]
|
||||
fn a_cone_bounded_only_by_its_base_circle_reaches_its_point() {
|
||||
let (r, h) = (6.0f64, 15.0f64);
|
||||
for apex_loop in [false, true] {
|
||||
let mut t = String::from(HEAD);
|
||||
t += "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=DIRECTION('',(0.,0.,-1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n";
|
||||
t += &format!("#5=CONICAL_SURFACE('',#4,{r:.1},{:.15});\n", (r / h).atan());
|
||||
t += "#6=DIRECTION('',(0.,0.,1.));\n#7=AXIS2_PLACEMENT_3D('',#1,#6,#3);\n#8=PLANE('',#7);\n";
|
||||
t += &format!("#9=CIRCLE('',#7,{r:.1});\n#10=CARTESIAN_POINT('',({r:.1},0.,0.));\n#11=VERTEX_POINT('',#10);\n");
|
||||
t += "#12=EDGE_CURVE('',#11,#11,#9,.T.);\n";
|
||||
t += "#20=ORIENTED_EDGE('',*,*,#12,.F.);\n#21=EDGE_LOOP('',(#20));\n#22=FACE_OUTER_BOUND('',#21,.T.);\n#23=ADVANCED_FACE('',(#22),#8,.F.);\n";
|
||||
t += "#30=ORIENTED_EDGE('',*,*,#12,.T.);\n#31=EDGE_LOOP('',(#30));\n#32=FACE_OUTER_BOUND('',#31,.T.);\n";
|
||||
if apex_loop {
|
||||
t += &format!(
|
||||
"#33=CARTESIAN_POINT('',(0.,0.,{h:.1}));\n#34=VERTEX_POINT('',#33);\n#35=VERTEX_LOOP('',#34);\n#36=FACE_BOUND('',#35,.T.);\n"
|
||||
);
|
||||
t += "#37=ADVANCED_FACE('',(#32,#36),#5,.T.);\n";
|
||||
} else {
|
||||
t += "#37=ADVANCED_FACE('',(#32),#5,.T.);\n";
|
||||
}
|
||||
t += "#80=CLOSED_SHELL('',(#23,#37));\n#81=MANIFOLD_SOLID_BREP('Cone',#80);\n";
|
||||
t += CONTEXT;
|
||||
t += TAIL;
|
||||
let imp = step_import(&t).unwrap();
|
||||
let body = &imp.bodies[0].body;
|
||||
let m = measure(body).unwrap();
|
||||
let exact = PI * r * r * h / 3.0;
|
||||
assert!(rel(m.volume, exact) < 2e-3, "vertex loop {apex_loop}: volume {} vs {exact}", m.volume);
|
||||
let top = body.tessellate(0.01).unwrap().positions.iter().map(|p| p.z).fold(f64::MIN, f64::max);
|
||||
assert!((top - h).abs() < 1e-6, "vertex loop {apex_loop}: reaches z = {top}, not {h}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ pub(crate) fn point(cx: &Ctx, id: u64) -> R<mt::Point3> {
|
||||
Ok(mt::Point3::new(g(0)?, g(1)?, g(2)?) * cx.len)
|
||||
}
|
||||
|
||||
fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
|
||||
pub(super) fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
|
||||
let e = entity(cx, id)?;
|
||||
if e.name() != "DIRECTION" {
|
||||
return Err(format!("#{id}: expected DIRECTION, found {}", e.name()));
|
||||
@@ -176,7 +176,32 @@ fn knot_vec(mults: Option<&Param>, knots: Option<&Param>, expected: usize) -> R<
|
||||
if m.len() > expected || m.iter().try_fold(0usize, |a, v| a.checked_add(*v)) != Some(expected) {
|
||||
return Err("knot multiplicities do not match the control points".into());
|
||||
}
|
||||
mt::KnotVec::from_single_multi(k, m).map_err(|e| format!("knot vector: {e}"))
|
||||
mt::KnotVec::from_single_multi(spanning_one(k), m).map_err(|e| format!("knot vector: {e}"))
|
||||
}
|
||||
|
||||
/// Shortest parameter range a spline is read with.
|
||||
const MIN_KNOT_RANGE: f64 = 1.0;
|
||||
|
||||
/// `knots` stretched, when they span less than [`MIN_KNOT_RANGE`], to span exactly that from the
|
||||
/// same first knot. A STEP writer may parameterize a spline over any range (some write a few
|
||||
/// millionths, as if in metres of a part drawn in millimetres), and the kernel tells parameters
|
||||
/// apart only to a fixed tolerance (1e-6): over such a range it took distinct knots for one, and
|
||||
/// trimming an edge to its vertices dropped a control point. Stretching the knots changes the
|
||||
/// parameters, never the curve (every point is where it was), and nothing read from the file
|
||||
/// refers to them: edges are trimmed by their vertices.
|
||||
fn spanning_one(knots: Vec<f64>) -> Vec<f64> {
|
||||
let (Some(&first), Some(&last)) = (knots.first(), knots.last()) else { return knots };
|
||||
let range = last - first;
|
||||
if !(range > 0.0 && range < MIN_KNOT_RANGE) {
|
||||
return knots;
|
||||
}
|
||||
let scale = MIN_KNOT_RANGE / range;
|
||||
let mut out: Vec<f64> = knots.iter().map(|k| first + (k - first) * scale).collect();
|
||||
// The last knot exactly at the end of the range (the product can miss it by a rounding).
|
||||
if let Some(k) = out.last_mut() {
|
||||
*k = first + MIN_KNOT_RANGE;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn generated_knots(kind: &str, n_ctrl: usize, degree: usize) -> R<mt::KnotVec> {
|
||||
@@ -539,15 +564,16 @@ fn sub_curve<C: Cut + BoundedCurve + Clone>(c: &C, a: f64, b: f64) -> C {
|
||||
out
|
||||
}
|
||||
|
||||
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge).
|
||||
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<C>>
|
||||
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge). The vertices may
|
||||
/// be up to `snap` off the curve; `tol` decides whether the curve closes on itself.
|
||||
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<C>>
|
||||
where
|
||||
C: ParametricCurve<Point = mt::Point3> + BoundedCurve + SearchNearestParameter<mt::D1, Point = mt::Point3> + Cut + Clone + Invertible,
|
||||
{
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let (ts, ds) = nearest_param(c, ps).ok_or("spline evaluation failed")?;
|
||||
let (te, de) = nearest_param(c, pe).ok_or("spline evaluation failed")?;
|
||||
if ds > tol || de > tol {
|
||||
if !(ds <= snap && de <= snap) {
|
||||
return Err(format!("edge vertices are {:.3e} mm off their curve", ds.max(de)));
|
||||
}
|
||||
let span = t1 - t0;
|
||||
@@ -589,9 +615,51 @@ where
|
||||
Ok(pieces)
|
||||
}
|
||||
|
||||
/// Moves one end of a spline piece (`back`: its end, else its start) onto its vertex `p` by
|
||||
/// the control point that end sits on, so the piece meets the vertex exactly and keeps its
|
||||
/// shape away from that end. A piece whose end is not a control point (an unclamped spline) is
|
||||
/// left as it is.
|
||||
fn pin_end(c: &mut mt::Curve, p: mt::Point3, back: bool) {
|
||||
let end = if back { c.back() } else { c.front() };
|
||||
let d = p - end;
|
||||
if !(d.magnitude2() > 0.0) {
|
||||
return;
|
||||
}
|
||||
let at_end = |q: mt::Point3| q.distance(end) <= mt::TOLERANCE;
|
||||
match c {
|
||||
mt::Curve::BSplineCurve(b) => {
|
||||
let cp = if back { b.control_points_mut().last() } else { b.control_points_mut().next() };
|
||||
if let Some(q) = cp.filter(|q| at_end(**q)) {
|
||||
*q += d;
|
||||
}
|
||||
}
|
||||
mt::Curve::NurbsCurve(n) => {
|
||||
let cp = if back { n.control_points_mut().last() } else { n.control_points_mut().next() };
|
||||
if let Some(q) = cp.filter(|q| q.w != 0.0 && at_end(mt::Point3::from_homogeneous(**q))) {
|
||||
*q = (mt::Point3::from_homogeneous(*q) + d).to_homogeneous() * q.w;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kernel curves for an edge from `ps` to `pe`: one piece, or two for a closed edge (so no
|
||||
/// kernel edge starts and ends at the same vertex).
|
||||
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<mt::Curve>> {
|
||||
/// kernel edge starts and ends at the same vertex). A spline or open-conic edge's ends are
|
||||
/// moved onto vertices up to `snap` off its curve, so the pieces meet the vertices exactly.
|
||||
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<mt::Curve>> {
|
||||
let mut pieces = curve_pieces(g, ps, pe, sense, closed, tol, snap)?;
|
||||
if matches!(g, CurveGeo::BSpline(_) | CurveGeo::Nurbs(_) | CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. }) {
|
||||
if let Some(c) = pieces.first_mut() {
|
||||
pin_end(c, ps, false);
|
||||
}
|
||||
if let Some(c) = pieces.last_mut() {
|
||||
pin_end(c, pe, true);
|
||||
}
|
||||
}
|
||||
Ok(pieces)
|
||||
}
|
||||
|
||||
fn curve_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<mt::Curve>> {
|
||||
match g {
|
||||
CurveGeo::Line { .. } => {
|
||||
if closed {
|
||||
@@ -628,15 +696,15 @@ pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: b
|
||||
}
|
||||
// The curve is open, so the edge is the parameters between its vertices: rising
|
||||
// for the same sense, falling for the opposite one.
|
||||
let (ts, te) = (open_conic_param(g, ps, tol)?, open_conic_param(g, pe, tol)?);
|
||||
let (ts, te) = (open_conic_param(g, ps, snap)?, open_conic_param(g, pe, snap)?);
|
||||
let scale = 1.0 + ts.abs().max(te.abs());
|
||||
if (te - ts).abs() <= 1e-9 * scale {
|
||||
return Err("degenerate conic edge".into());
|
||||
}
|
||||
Ok(vec![open_conic_curve(g, ts, te)?])
|
||||
}
|
||||
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
|
||||
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
|
||||
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol, snap)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
|
||||
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol, snap)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,9 @@ pub(crate) struct Ctx<'a> {
|
||||
pub ang: f64,
|
||||
/// Vertex-on-curve tolerance (mm).
|
||||
pub tol: f64,
|
||||
/// Farthest an edge's vertex may sit off its curve and still be moved onto it (mm, at least
|
||||
/// `tol`): see [`snap_limit`].
|
||||
pub snap: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -121,9 +124,29 @@ struct Units {
|
||||
len: f64,
|
||||
ang: f64,
|
||||
tol: f64,
|
||||
snap: f64,
|
||||
}
|
||||
|
||||
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01 };
|
||||
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01, snap: 0.01 };
|
||||
|
||||
/// Largest tolerance a file can ask for (mm), however coarse the accuracy it declares.
|
||||
const MAX_TOL: f64 = 1.0;
|
||||
|
||||
/// Gap between an edge's vertex and its curve that is still read as the same point, as a share
|
||||
/// of the model's size: the chord error the kernel's own coarse meshes allow at that size
|
||||
/// (`Body::size() * 1e-3`), so moving the vertex onto the curve changes the shape by less than
|
||||
/// those meshes already do.
|
||||
const SNAP_SHARE: f64 = 1e-3;
|
||||
|
||||
/// Farthest a vertex may sit off its edge's curve and be moved onto it (mm): the file's own
|
||||
/// tolerance `tol`, or up to [`SNAP_SHARE`] of the model's size `extent` (mm) when that is
|
||||
/// larger. Some exporters write vertices further off than the accuracy they declare (a surface
|
||||
/// export may declare 0.01 mm and join two curves 0.38 mm apart at a vertex between them).
|
||||
/// Never more than [`MAX_TOL`], so a far stray point cannot turn the check off.
|
||||
fn snap_limit(tol: f64, extent: f64) -> f64 {
|
||||
let share = if extent.is_finite() { extent * SNAP_SHARE } else { 0.0 };
|
||||
tol.max(share.min(MAX_TOL))
|
||||
}
|
||||
|
||||
fn si_prefix(p: Option<&Param>) -> f64 {
|
||||
match p.and_then(Param::as_enum) {
|
||||
@@ -208,7 +231,8 @@ fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units
|
||||
}
|
||||
}
|
||||
}
|
||||
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, 1.0);
|
||||
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, MAX_TOL);
|
||||
u.snap = snap_limit(u.tol, ex.vertex_extent() * u.len);
|
||||
cache.insert(id, u);
|
||||
u
|
||||
}
|
||||
@@ -560,12 +584,41 @@ impl Reader {
|
||||
}
|
||||
|
||||
fn placement_matrix(&mut self, item: u64, u: Units) -> R<mt::Matrix4> {
|
||||
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol };
|
||||
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol, snap: u.snap };
|
||||
let e = geom::entity(&cx, item)?;
|
||||
if e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" {
|
||||
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
|
||||
let p = e.params();
|
||||
let o = geom::point(&cx, p.get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
|
||||
return Ok(mt::Matrix4::from_translation(o.to_homogeneous().truncate()));
|
||||
let direction = |i: usize, fallback: mt::Vector3| -> R<mt::Vector3> {
|
||||
match p.get(i) {
|
||||
None | Some(Param::Null) => Ok(fallback),
|
||||
Some(Param::Ref(id)) => geom::direction(&cx, *id),
|
||||
_ => Err("invalid transformation direction".into()),
|
||||
}
|
||||
};
|
||||
let scale = |i: usize, fallback: f64| -> R<f64> {
|
||||
match p.get(i) {
|
||||
None | Some(Param::Null) => Ok(fallback),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x != 0.0).ok_or_else(|| "invalid transformation scale".into()),
|
||||
}
|
||||
};
|
||||
let sx = scale(5, 1.0)?;
|
||||
let (sy, sz) = (scale(7, sx)?, scale(8, sx)?);
|
||||
let m = mt::Matrix4::from_cols(
|
||||
(direction(2, mt::Vector3::unit_x())? * sx).extend(0.0),
|
||||
(direction(3, mt::Vector3::unit_y())? * sy).extend(0.0),
|
||||
(direction(6, mt::Vector3::unit_z())? * sz).extend(0.0),
|
||||
o.to_homogeneous(),
|
||||
);
|
||||
if !to_array(&m).iter().flatten().all(|x| x.is_finite()) {
|
||||
return Err("invalid transformation scale".into());
|
||||
}
|
||||
use mt::SquareMatrix as _;
|
||||
let det = m.determinant();
|
||||
if !det.is_finite() || det == 0.0 {
|
||||
return Err("singular transformation".into());
|
||||
}
|
||||
return Ok(m);
|
||||
}
|
||||
Ok(geom::frame(&cx, item)?.matrix())
|
||||
}
|
||||
@@ -732,7 +785,7 @@ impl Reader {
|
||||
};
|
||||
let placement = self.batches.get(p.batch).map_or(mt::Matrix4::from_scale(1.0), |b| b.m);
|
||||
sources.push((i, item));
|
||||
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item) });
|
||||
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item), tol: units.tol });
|
||||
}
|
||||
// Reading and orienting new parts is shared out between the workers.
|
||||
let prepared = parallel::bodies(&ex, &jobs, self.workers);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user