mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 18:45:55 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd45fc5fe6 |
@@ -2,8 +2,3 @@ self-hosted-runner:
|
||||
labels:
|
||||
- self-hosted-fast
|
||||
- self-hosted-light
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-15
|
||||
|
||||
+43
-41
@@ -27,18 +27,16 @@
|
||||
# jobs, so they never wait behind a build). For pushes to main and the merge queue only, the
|
||||
# repository checks, Clippy, the doc tests, the reference tests and all four test shards run on the
|
||||
# self-hosted runners (`self-hosted-fast`, warm build directories via scripts/ci/warm-target.sh);
|
||||
# a pull request runs them on Blacksmith's runners (blacksmith-<N>vcpu-ubuntu-2404, ephemeral
|
||||
# VMs sized per job), so it never holds up the queue. Every fork PR's Linux jobs run there too.
|
||||
# The self-hosted-untrusted pool (isolated gVisor boxes) is not routed to now; to use it again,
|
||||
# send fork PR jobs to `self-hosted-untrusted` in the `runs-on` of the jobs that had it.
|
||||
# Where the test shards run on Blacksmith's runners, the test archive is built there too: the
|
||||
# self-hosted machine has a newer glibc, so its binaries wouldn't start on Ubuntu 24.04, while the
|
||||
# a pull request runs them on GitHub's runners, so it never holds up the queue. Fork PRs' Linux
|
||||
# jobs run on the isolated, ephemeral `self-hosted-untrusted` runners (no warm caches) when the
|
||||
# PR number ends in 7, 8 or 9 (the rest of the fork PRs, and everything else, run on GitHub's
|
||||
# runners, which have far more free slots).
|
||||
# Where the test shards run on GitHub's runners, the test archive is built there too: the
|
||||
# self-hosted machine has a newer glibc, so its binaries wouldn't start on ubuntu-24.04, while the
|
||||
# archive runs there unchanged (same /home/runner/work checkout path). The self-hosted shards
|
||||
# (main and the merge queue) skip the archive and build the tests in their warm build directories.
|
||||
# Blacksmith's runners count as self-hosted to GitHub, so steps tell our machines apart by
|
||||
# runner name (minisforum-*, ci-box-*), not by runner.environment. The Windows jobs run on
|
||||
# GitHub's runners. The nightly run and Run workflow use Blacksmith's runners only; the
|
||||
# self-hosted runners refuse any other job.
|
||||
# Fork pull requests, the nightly run and Run workflow use GitHub's runners only; the self-hosted
|
||||
# runners refuse any other job.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
@@ -79,7 +77,7 @@ jobs:
|
||||
dependency-advisories:
|
||||
name: Dependency advisories
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -95,7 +93,7 @@ jobs:
|
||||
# approved-for-ci either (the same skip, for the same reason).
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
# Trusted runs (see the top of this file) use the self-hosted runners.
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
permissions:
|
||||
contents: read
|
||||
# To ask whether the merge queue already tested a commit pushed to main.
|
||||
@@ -111,8 +109,8 @@ jobs:
|
||||
packages: ${{ steps.paths.outputs.packages }}
|
||||
oracle: ${{ steps.paths.outputs.oracle }}
|
||||
# The fast self-hosted runners are kept for main and the merge queue (the pre-merge gate);
|
||||
# pull requests, same-repo ones included, run on Blacksmith's runners.
|
||||
local: ${{ (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) && github.event_name != 'pull_request' }}
|
||||
# pull requests, same-repo ones included, take the GitHub-hosted or untrusted paths.
|
||||
local: ${{ runner.environment == 'self-hosted' && github.event_name != 'pull_request' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -225,18 +223,19 @@ jobs:
|
||||
checks:
|
||||
name: Format and repository checks
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh checks
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: repo-checks
|
||||
@@ -267,14 +266,14 @@ jobs:
|
||||
name: Quick compile
|
||||
needs: changes
|
||||
if: needs.changes.outputs.quick == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
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
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
- if: runner.environment == 'github-hosted'
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: quick-compile
|
||||
@@ -286,18 +285,19 @@ jobs:
|
||||
name: Native Linux Clippy
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-16vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh clippy
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: native-clippy
|
||||
@@ -318,7 +318,7 @@ jobs:
|
||||
# The self-hosted shards (main and the merge queue) build from their warm build directories
|
||||
# instead: downloading the 3 GB archive took them longer than building.
|
||||
if: needs.changes.outputs.native == 'true' && needs.changes.outputs.local != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
|
||||
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
|
||||
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 90 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
|
||||
env:
|
||||
@@ -365,7 +365,7 @@ jobs:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: [changes, build-tests]
|
||||
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (needs.changes.outputs.local == 'true' || needs.build-tests.result == 'success') }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -382,7 +382,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install a software Vulkan driver (lavapipe)
|
||||
# Preinstalled on the self-hosted runner, which has no sudo.
|
||||
if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
if: runner.environment == 'github-hosted'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
|
||||
@@ -464,7 +464,7 @@ jobs:
|
||||
name: Action matrix (${{ matrix.shard }}/16)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 55
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -506,7 +506,7 @@ jobs:
|
||||
name: Action matrix determinism
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -528,7 +528,7 @@ jobs:
|
||||
name: Property tests (many cases)
|
||||
needs: build-tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -552,7 +552,7 @@ jobs:
|
||||
name: Doc tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -564,11 +564,12 @@ jobs:
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh doc-tests
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: doc-tests
|
||||
@@ -604,7 +605,7 @@ jobs:
|
||||
name: Reference tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.oracle == 'true'
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || 'blacksmith-8vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
@@ -616,11 +617,12 @@ jobs:
|
||||
- name: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- name: Warm build directory (self-hosted runner)
|
||||
if: (startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-'))
|
||||
if: runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
run: scripts/ci/warm-target.sh oracle
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
# Our self-hosted runners keep their build directory and Cargo home instead.
|
||||
- if: ${{ !(startsWith(runner.name, 'minisforum-') || startsWith(runner.name, 'ci-box-')) }}
|
||||
# The trusted self-hosted runners keep their build directory and Cargo home instead; the
|
||||
# untrusted ones (fork pull requests) start empty and restore main's cache, read-only.
|
||||
- if: runner.environment == 'github-hosted' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: oracle
|
||||
@@ -704,7 +706,7 @@ jobs:
|
||||
name: ${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && 'ci (ignored label)' || 'ci' }}
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||||
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
@@ -748,7 +750,7 @@ jobs:
|
||||
wasm:
|
||||
name: Compile WebAssembly (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -764,7 +766,7 @@ jobs:
|
||||
riscv64-gates:
|
||||
name: Compile RISC-V (manual)
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.cross_compile
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -27,7 +27,7 @@ concurrency:
|
||||
jobs:
|
||||
claim:
|
||||
name: Claim bot
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
|
||||
@@ -34,7 +34,7 @@ env:
|
||||
jobs:
|
||||
image:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -21,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
publish:
|
||||
name: Request the combined docs, web, and Flatpak deployment
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
|
||||
@@ -18,7 +18,7 @@ permissions:
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate FreeCAD expected values
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrency:
|
||||
jobs:
|
||||
regenerate:
|
||||
name: Regenerate and commit
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
# Always main itself, never pull request code, since this job can push.
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
jobs:
|
||||
format-changes:
|
||||
name: Design format changes merge cleanly
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
sorted:
|
||||
name: Lists are sorted one item per line
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -20,7 +20,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
|
||||
@@ -31,7 +31,7 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
@@ -45,7 +45,7 @@ env:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
build: ${{ steps.changed.outputs.build }}
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
@@ -172,8 +172,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -210,8 +210,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
@@ -392,7 +392,7 @@ jobs:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: creds
|
||||
name: Check for Snap Store credentials
|
||||
|
||||
@@ -33,7 +33,7 @@ concurrency:
|
||||
jobs:
|
||||
triage:
|
||||
name: Triage bot
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
# Filtered here so the common events (every comment, every label) start no runner.
|
||||
if: >-
|
||||
|
||||
@@ -29,7 +29,7 @@ env:
|
||||
|
||||
jobs:
|
||||
submit:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
name: Check for the token and the package
|
||||
|
||||
@@ -349,8 +349,6 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
|
||||
meet (a sphere for equal radii); perpendicular corner fillets after a closed ring of earlier
|
||||
rounds preserve the ring's radii and the enclosed planar face (#1557);
|
||||
mitres between separate rounds of the same radius keep their endpoint tangent, so a later
|
||||
fillet can select an upright and its adjoining bottom mitre as one chain (#2422);
|
||||
straight edges (convex or concave) between planar faces with perpendicular
|
||||
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
|
||||
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
|
||||
|
||||
@@ -625,10 +625,7 @@ mod tests {
|
||||
wheel: WheelGuard::default(),
|
||||
started: now,
|
||||
};
|
||||
// A window that has run a frame with focus, as the app's always has by now.
|
||||
let ctx = Context::default();
|
||||
let _ = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
mouse.tick_at(&mut app, &ctx, now);
|
||||
mouse.tick_at(&mut app, &Context::default(), now);
|
||||
let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion");
|
||||
assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] });
|
||||
assert!(seconds > 0.0);
|
||||
|
||||
@@ -687,15 +687,6 @@
|
||||
"joint.from_bodies_hint": "Každé vybrané těleso se přesune do vlastní nové komponenty a zůstane na místě.",
|
||||
"joint.one_body": "Oba výběry jsou na {body}. Druhý vyberte na jiném dílu.",
|
||||
"joint.one_component": "Tyto díly jsou tělesy jedné komponenty: {bodies}. Kloub pohybuje jednou komponentou vůči druhé, takže každý díl potřebuje vlastní komponentu.",
|
||||
"messages.ago_h": "před {n} h",
|
||||
"messages.ago_min": "před {n} min",
|
||||
"messages.ago_s": "před {n} s",
|
||||
"messages.chip_hint": "Klikněte pro všechny zprávy této relace",
|
||||
"messages.clear": "Vymazat",
|
||||
"messages.copy_all": "Kopírovat vše",
|
||||
"messages.menu": "Zprávy…",
|
||||
"messages.none": "Zatím žádné zprávy.",
|
||||
"messages.title": "Zprávy",
|
||||
"nav.touchpad.help": "Podržením Shift a pohybem ukazatele posouváte; podržením Alt/Option a pohybem otáčíte. Tlačítko myši není potřeba. Svislé posouvání přibližuje.",
|
||||
"nav.touchpad.label": "Touchpad",
|
||||
"nav.touchpad.orbit": "Otáčení (podržte Alt/Option a pohybujte; bez tlačítka myši)",
|
||||
|
||||
@@ -719,15 +719,6 @@
|
||||
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.",
|
||||
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
|
||||
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
|
||||
"messages.ago_h": "{n} h ago",
|
||||
"messages.ago_min": "{n} min ago",
|
||||
"messages.ago_s": "{n} s ago",
|
||||
"messages.chip_hint": "Click for all messages of this session",
|
||||
"messages.clear": "Clear",
|
||||
"messages.copy_all": "Copy all",
|
||||
"messages.menu": "Messages…",
|
||||
"messages.none": "No messages yet.",
|
||||
"messages.title": "Messages",
|
||||
"motion.checked_to": "Checked to step {step} only: the check ran out of its work budget",
|
||||
"motion.collision": "{a} · {b}: steps {steps}",
|
||||
"motion.duration_hint": "seconds (empty: per step)",
|
||||
@@ -1355,6 +1346,10 @@
|
||||
"ui.newSketchGroup": "New Sketch Group",
|
||||
"ui.new_body": "New Body",
|
||||
"ui.new_design": "New Design",
|
||||
"ui.new_extrude_distance": "New extrude distance",
|
||||
"ui.new_extrude_distance_fixed": "Fixed (10 mm)",
|
||||
"ui.new_extrude_distance_last_used": "Last used",
|
||||
"ui.new_extrude_distance_tip": "The distance a new Extrude starts with. Editing an existing extrude keeps its own.",
|
||||
"ui.no_slicer_found": "No slicer found",
|
||||
"ui.normal": "Normal",
|
||||
"ui.normalConstruction": "Normal / Construction",
|
||||
|
||||
@@ -704,15 +704,6 @@
|
||||
"joint.from_bodies_hint": "Ogni corpo selezionato viene spostato in un nuovo componente distinto, mantenendo la sua posizione.",
|
||||
"joint.one_body": "Entrambe le selezioni sono su {body}. Seleziona il secondo elemento su un'altra parte.",
|
||||
"joint.one_component": "Queste parti sono corpi di un unico componente: {bodies}. Un giunto muove un componente rispetto a un altro, quindi ogni parte richiede un componente distinto.",
|
||||
"messages.ago_h": "{n} h fa",
|
||||
"messages.ago_min": "{n} min fa",
|
||||
"messages.ago_s": "{n} s fa",
|
||||
"messages.chip_hint": "Fai clic per tutti i messaggi di questa sessione",
|
||||
"messages.clear": "Cancella",
|
||||
"messages.copy_all": "Copia tutto",
|
||||
"messages.menu": "Messaggi…",
|
||||
"messages.none": "Ancora nessun messaggio.",
|
||||
"messages.title": "Messaggi",
|
||||
"motion.checked_to": "Controllato solo fino al passo {step}: il controllo ha esaurito il limite di calcolo",
|
||||
"motion.collision": "{a} · {b}: passi {steps}",
|
||||
"motion.duration_hint": "secondi (vuoto: per passo)",
|
||||
|
||||
@@ -719,15 +719,6 @@
|
||||
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。",
|
||||
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
|
||||
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
|
||||
"messages.ago_h": "{n} 時間前",
|
||||
"messages.ago_min": "{n} 分前",
|
||||
"messages.ago_s": "{n} 秒前",
|
||||
"messages.chip_hint": "クリックするとこのセッションのすべてのメッセージを表示します",
|
||||
"messages.clear": "クリア",
|
||||
"messages.copy_all": "すべてコピー",
|
||||
"messages.menu": "メッセージ…",
|
||||
"messages.none": "まだメッセージはありません。",
|
||||
"messages.title": "メッセージ",
|
||||
"motion.checked_to": "ステップ {step} までしか確認していません: 確認の計算量の上限に達しました",
|
||||
"motion.collision": "{a} · {b}: ステップ {steps}",
|
||||
"motion.duration_hint": "秒 (空欄: ステップごと)",
|
||||
@@ -1355,6 +1346,10 @@
|
||||
"ui.newSketchGroup": "新しいスケッチグループ",
|
||||
"ui.new_body": "新規ボディ",
|
||||
"ui.new_design": "新規デザイン",
|
||||
"ui.new_extrude_distance": "新規押し出しの距離",
|
||||
"ui.new_extrude_distance_fixed": "固定 (10 mm)",
|
||||
"ui.new_extrude_distance_last_used": "前回の値",
|
||||
"ui.new_extrude_distance_tip": "新しい押し出しの開始距離です。既存の押し出しを編集するときは、その押し出しの値が使われます。",
|
||||
"ui.no_slicer_found": "スライサーが見つかりません",
|
||||
"ui.normal": "標準",
|
||||
"ui.normalConstruction": "通常 / コンストラクション",
|
||||
|
||||
@@ -719,15 +719,6 @@
|
||||
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。",
|
||||
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
|
||||
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
|
||||
"messages.ago_h": "{n} 小时前",
|
||||
"messages.ago_min": "{n} 分钟前",
|
||||
"messages.ago_s": "{n} 秒前",
|
||||
"messages.chip_hint": "点击查看本次会话的所有消息",
|
||||
"messages.clear": "清除",
|
||||
"messages.copy_all": "全部复制",
|
||||
"messages.menu": "消息…",
|
||||
"messages.none": "暂无消息。",
|
||||
"messages.title": "消息",
|
||||
"motion.checked_to": "仅检查到第 {step} 步:检查已用完其工作量上限",
|
||||
"motion.collision": "{a} · {b}:第 {steps} 步",
|
||||
"motion.duration_hint": "秒(留空:按步)",
|
||||
@@ -1355,6 +1346,10 @@
|
||||
"ui.newSketchGroup": "新建草图组",
|
||||
"ui.new_body": "新建实体",
|
||||
"ui.new_design": "新建设计",
|
||||
"ui.new_extrude_distance": "新建拉伸距离",
|
||||
"ui.new_extrude_distance_fixed": "固定 (10 mm)",
|
||||
"ui.new_extrude_distance_last_used": "上次使用",
|
||||
"ui.new_extrude_distance_tip": "新建拉伸的起始距离。编辑已有拉伸时保留其自身的值。",
|
||||
"ui.no_slicer_found": "未找到切片软件",
|
||||
"ui.normal": "法向",
|
||||
"ui.normalConstruction": "普通 / 构造",
|
||||
|
||||
+6
-25
@@ -1,8 +1,5 @@
|
||||
//! Timeline evaluation.
|
||||
|
||||
#[path = "placed_mesh.rs"]
|
||||
mod placed_mesh;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
@@ -31,8 +28,6 @@ pub struct ModelBody {
|
||||
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
|
||||
// meshing any snapshot also warms every unchanged copy of this body.
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
/// Lazy canonical reference triangles for a proper rigid occurrence placement.
|
||||
mesh_placement: Option<Arc<placed_mesh::PlacedMesh>>,
|
||||
/// The display plan (none when the body has no plan: a mesh body, or one the planner
|
||||
/// refuses), shared like the mesh. A GPU viewport draws it.
|
||||
plan: Arc<[OnceLock<Option<Arc<TessPlan>>>; 4]>,
|
||||
@@ -66,7 +61,6 @@ impl ModelBody {
|
||||
body,
|
||||
feature,
|
||||
mesh: Arc::new(OnceLock::new()),
|
||||
mesh_placement: None,
|
||||
plan: Arc::new(Default::default()),
|
||||
plan_mesh: Arc::new(Default::default()),
|
||||
key: 0,
|
||||
@@ -89,10 +83,9 @@ impl ModelBody {
|
||||
// finding their surface, picks naming their face, sketch planes), and a different
|
||||
// triangulation moved those and changed what designs build. A GPU viewport draws the
|
||||
// plan ([`ModelBody::display_plan`]).
|
||||
match self.mesh_placement.as_deref() {
|
||||
Some(placed) => self.mesh.get_or_init(|| placed.mesh()).clone(),
|
||||
None => placed_mesh::reference_mesh(&self.body, &self.mesh, self.display_tol()),
|
||||
}
|
||||
self.mesh
|
||||
.get_or_init(|| Arc::new(self.body.reference_display_mesh_angular(self.display_tol(), kernel::DISPLAY_ANGLE).unwrap_or_default()))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The display plan of this body at the viewport's display quality
|
||||
@@ -152,10 +145,9 @@ impl ModelBody {
|
||||
Some((Arc::new(plan), Arc::new(mesh)))
|
||||
}
|
||||
|
||||
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the definition's size, at least
|
||||
/// 1 µm. Proper rigid placements retain that tolerance along with the reference triangles.
|
||||
/// The chord tolerance of [`ModelBody::mesh`] (mm): 1/1000 of the body's size, at least 1 µm.
|
||||
pub fn display_tol(&self) -> f64 {
|
||||
self.mesh_placement.as_ref().map(|placed| placed.tolerance).unwrap_or_else(|| (self.body.size() * 1e-3).max(1e-3))
|
||||
(self.body.size() * 1e-3).max(1e-3)
|
||||
}
|
||||
|
||||
/// Already computed display geometry; saving must not trigger cold tessellation.
|
||||
@@ -4364,9 +4356,6 @@ fn replay(
|
||||
continue;
|
||||
}
|
||||
let (op, targets) = feature_op(src);
|
||||
// A primitive's or a hole's targets are its participants (#1108): the copies change
|
||||
// those bodies only, like the feature itself (#2755).
|
||||
let targets = if targets.is_empty() { src.participants.clone() } else { targets };
|
||||
let mut copies = Vec::new();
|
||||
if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind {
|
||||
// A hole is drilled again at each copy of its spot (found on the surface there), so
|
||||
@@ -5718,19 +5707,11 @@ impl PlacedBodies {
|
||||
Some((_, placed)) => {
|
||||
let mut fresh = ModelBody::new(name, placed.body.clone(), b.feature);
|
||||
fresh.mesh = placed.mesh.clone();
|
||||
fresh.mesh_placement = placed.mesh_placement.clone();
|
||||
fresh.plan = placed.plan.clone();
|
||||
fresh.plan_mesh = placed.plan_mesh.clone();
|
||||
fresh
|
||||
}
|
||||
None => match kernel::transform_matrix(&b.body, *m) {
|
||||
Ok(body) => {
|
||||
let mut fresh = ModelBody::new(name, body, b.feature);
|
||||
fresh.mesh_placement = placed_mesh::PlacedMesh::new(b, m).map(Arc::new);
|
||||
fresh
|
||||
}
|
||||
Err(_) => ModelBody::new(name, b.body.clone(), b.feature),
|
||||
},
|
||||
None => ModelBody::new(name, kernel::transform_matrix(&b.body, *m).unwrap_or_else(|_| b.body.clone()), b.feature),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Reference triangles belong to a definition, even while its occurrences move. Keep one lazy
|
||||
//! source mesh and map it into each pose; retain the exact placed B-rep separately in ModelBody.
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_kernel::{self as kernel, Body};
|
||||
|
||||
use super::ModelBody;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct PlacedMesh {
|
||||
body: Body,
|
||||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||||
transform: crate::Mat,
|
||||
pub(super) tolerance: f64,
|
||||
}
|
||||
|
||||
impl PlacedMesh {
|
||||
/// Share the original definition, never a chain of previously placed meshes. A source's
|
||||
/// OnceLock lives with its immutable geometry, so edits get a new source automatically.
|
||||
pub(super) fn new(source: &ModelBody, transform: &crate::Mat) -> Option<Self> {
|
||||
if !proper_rigid(transform) {
|
||||
return None;
|
||||
}
|
||||
Some(match source.mesh_placement.as_deref() {
|
||||
Some(original) => Self {
|
||||
body: original.body.clone(),
|
||||
mesh: original.mesh.clone(),
|
||||
transform: crate::mat_mul(transform, &original.transform),
|
||||
tolerance: original.tolerance,
|
||||
},
|
||||
None => Self { body: source.body.clone(), mesh: source.mesh.clone(), transform: *transform, tolerance: source.display_tol() },
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn mesh(&self) -> Arc<Mesh> {
|
||||
let source = reference_mesh(&self.body, &self.mesh, self.tolerance);
|
||||
Arc::new(Mesh {
|
||||
positions: source.positions.iter().map(|p| crate::apply_point(&self.transform, *p)).collect(),
|
||||
normals: source.normals.iter().map(|n| crate::apply_vector(&self.transform, *n)).collect(),
|
||||
edges: source.edges.iter().map(|edge| edge.iter().map(|p| crate::apply_point(&self.transform, *p)).collect()).collect(),
|
||||
triangles: source.triangles.clone(),
|
||||
tri_face: source.tri_face.clone(),
|
||||
edge_faces: source.edge_faces.clone(),
|
||||
seams: source.seams.clone(),
|
||||
smooth_edges: source.smooth_edges.clone(),
|
||||
sharp_edges: source.sharp_edges.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reference_mesh(body: &Body, mesh: &OnceLock<Arc<Mesh>>, tolerance: f64) -> Arc<Mesh> {
|
||||
mesh.get_or_init(|| Arc::new(body.reference_display_mesh_angular(tolerance, kernel::DISPLAY_ANGLE).unwrap_or_default())).clone()
|
||||
}
|
||||
|
||||
/// Only proper rotations and translations preserve triangle winding, normal lengths and the
|
||||
/// reference chord tolerance. Scale, shear, reflection and malformed matrices use the mesher.
|
||||
fn proper_rigid(m: &crate::Mat) -> bool {
|
||||
if m.iter().flatten().any(|v| !v.is_finite()) || m[0][3] != 0. || m[1][3] != 0. || m[2][3] != 0. || m[3][3] != 1. {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])];
|
||||
[x, y, z].iter().all(|v| (v.dot(*v) - 1.).abs() < 1e-9)
|
||||
&& x.dot(y).abs() < 1e-9
|
||||
&& x.dot(z).abs() < 1e-9
|
||||
&& y.dot(z).abs() < 1e-9
|
||||
&& (x.cross(y).dot(z) - 1.).abs() < 1e-9
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placed_mesh_is_lazy_and_composes_from_one_immutable_source() {
|
||||
let source = ModelBody::new("Part".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1);
|
||||
let a = crate::rigid(Vec3::new(10., 20., 30.), Vec3::ZERO, Vec3::Z, 0.7);
|
||||
let b = crate::rigid(Vec3::new(3., -5., 2.), Vec3::ZERO, Vec3::X, 0.4);
|
||||
let mut first = ModelBody::new("First".into(), kernel::transform_matrix(&source.body, a).unwrap(), 1);
|
||||
first.mesh_placement = Some(Arc::new(PlacedMesh::new(&source, &a).unwrap()));
|
||||
let mut nested = ModelBody::new("Nested".into(), kernel::transform_matrix(&first.body, b).unwrap(), 1);
|
||||
nested.mesh_placement = Some(Arc::new(PlacedMesh::new(&first, &b).unwrap()));
|
||||
assert_eq!(first.display_tol(), source.display_tol());
|
||||
assert_eq!(nested.display_tol(), source.display_tol());
|
||||
assert!(source.cached_mesh().is_none());
|
||||
assert!(first.cached_mesh().is_none());
|
||||
let mesh = nested.mesh();
|
||||
assert!(source.cached_mesh().is_some(), "the canonical source is warmed on demand");
|
||||
assert!(first.cached_mesh().is_none(), "composition does not retain or evaluate intermediate poses");
|
||||
let local = source.mesh();
|
||||
let matrix = crate::mat_mul(&b, &a);
|
||||
for (p, q) in local.positions.iter().zip(&mesh.positions) {
|
||||
assert!(crate::apply_point(&matrix, *p).dist(*q) < 1e-8);
|
||||
}
|
||||
let (other, work) = kernel::profile::capture(|| first.mesh());
|
||||
assert_eq!(work.tessellate_ms, 0.);
|
||||
assert_eq!(other.tri_face, local.tri_face);
|
||||
assert!(Arc::ptr_eq(&mesh, &nested.mesh()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_instances_warm_one_definition_and_an_edit_replaces_it() {
|
||||
let mut doc = crate::Document::new("instances");
|
||||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||||
doc.body_components.insert("Pin".into(), component);
|
||||
let first = doc.occurrences[0].id;
|
||||
doc.occurrences[0].transform = crate::rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Y, 0.3);
|
||||
doc.add_occurrence(component, 0, crate::rigid(Vec3::new(0., 20., 0.), Vec3::ZERO, Vec3::X, 0.6)).unwrap();
|
||||
let mut state = crate::ModelState::default();
|
||||
state.bodies.push(ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 2., 8.).unwrap(), 1));
|
||||
let mut cache = super::super::PlacedBodies::default();
|
||||
let before = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
assert!(state.bodies[0].cached_mesh().is_none(), "placing bodies must remain lazy");
|
||||
let old = before.bodies[0].mesh();
|
||||
assert!(state.bodies[0].cached_mesh().is_some());
|
||||
let (_, work) = kernel::profile::capture(|| before.bodies[1].mesh());
|
||||
assert_eq!(work.tessellate_ms, 0., "the second instance shares the first one's triangulation");
|
||||
doc.occurrences.iter_mut().find(|o| o.id == first).unwrap().transform = crate::rigid(Vec3::new(30., 0., 0.), Vec3::ZERO, Vec3::Y, 0.9);
|
||||
let moved = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
let (_, work) = kernel::profile::capture(|| moved.bodies[0].mesh());
|
||||
assert_eq!(work.tessellate_ms, 0., "a new pose shares the same definition");
|
||||
state.bodies[0] = ModelBody::new("Pin".into(), kernel::cylinder(Vec3::ZERO, Vec3::Z, 4., 8.).unwrap(), 1);
|
||||
let edited = super::super::world_state_reusing(&doc, &state, &mut cache);
|
||||
assert!(state.bodies[0].cached_mesh().is_none());
|
||||
let new = edited.bodies[0].mesh();
|
||||
assert!(state.bodies[0].cached_mesh().is_some(), "changed geometry warms its new definition cache");
|
||||
assert_ne!(new.bounds(), moved.bodies[0].mesh().bounds());
|
||||
assert!(Arc::ptr_eq(&old, &before.bodies[0].mesh()), "editing does not mutate older snapshots");
|
||||
}
|
||||
#[test]
|
||||
fn only_proper_rigid_affine_matrices_reuse_reference_meshes() {
|
||||
let source = ModelBody::new("Part".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1);
|
||||
assert!(PlacedMesh::new(&source, &crate::IDENTITY).is_some());
|
||||
for (col, row, value) in [(0, 0, -1.), (0, 0, 2.), (0, 1, 0.5), (0, 3, 0.1), (3, 3, 2.), (3, 0, f64::NAN), (3, 2, f64::INFINITY)] {
|
||||
let mut matrix = crate::IDENTITY;
|
||||
matrix[col][row] = value;
|
||||
assert!(PlacedMesh::new(&source, &matrix).is_none(), "{matrix:?}");
|
||||
}
|
||||
// A positive unit determinant alone is insufficient: this scale changes normals and tolerance.
|
||||
let mut scale = crate::IDENTITY;
|
||||
scale[0][0] = 2.;
|
||||
scale[1][1] = 0.5;
|
||||
assert!(PlacedMesh::new(&source, &scale).is_none());
|
||||
}
|
||||
}
|
||||
+29
-226
@@ -25,7 +25,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("delete")
|
||||
.key("Delete")
|
||||
.params("feature: id|name | features: [id|name] (one undo step; a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
.params("features: [id or name] (a sketch takes its dependent features with it) → deleted: [ids]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
@@ -41,9 +41,9 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot; atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active, or \"end\" (omit = end)"),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", rollback).icon("rollback")
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
.params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
.params("feature: id|name, position: its new index; refused when a feature that worked would fail"),
|
||||
CommandSpec::new("timeline.reorder_many", "Reorder Features", reorder_many).icon("reorder").params(
|
||||
@@ -54,15 +54,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
|
||||
.noundo()
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params(
|
||||
"feature: id|name | features: [id|name] (one undo step), suppressed?: bool (default toggles: several features are all \
|
||||
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]), suppressed",
|
||||
),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params("feature: id|name, suppressed?: bool (default toggles)"),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
a dotted key names a nested field ({\"plane.distance\": \"8 mm\"} = {\"plane\": {\"distance\": \"8 mm\"}}); \
|
||||
expressions are text, or a number in mm or degrees} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
expressions are text} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
|
||||
"feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
@@ -513,17 +509,7 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, Some(f), "timeline.roll_to")?;
|
||||
s.doc.feature_index(id).map(|i| i + 1)
|
||||
}
|
||||
// A number of features, or "end" (as omitting it); anything else is refused rather than
|
||||
// read as "end".
|
||||
None => match p.get("position") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(t)) if t.trim().eq_ignore_ascii_case("end") => None,
|
||||
Some(v) => {
|
||||
let x = v.as_f64().or_else(|| v.as_str().and_then(|t| t.trim().parse::<f64>().ok())).and_then(crate::params::finite);
|
||||
let why = "`position` must be a number of features to keep active, or \"end\"";
|
||||
Some(x.map(|x| x.max(0.0) as usize).ok_or_else(|| bad("timeline.roll_to", why))?)
|
||||
}
|
||||
},
|
||||
None => num(p, "position").map(|x| x.max(0.0) as usize),
|
||||
};
|
||||
s.doc_mut().marker = match pos {
|
||||
Some(k) if k < n => Some(k),
|
||||
@@ -533,52 +519,12 @@ fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n}))
|
||||
}
|
||||
|
||||
/// The features named by `feature: id|name` and/or `features: [id|name]` (or one id|name), in
|
||||
/// the order given and without repeats. Each must exist.
|
||||
fn feature_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<u64>> {
|
||||
let mut given: Vec<&Value> = Vec::new();
|
||||
if let Some(f) = p.get("feature").filter(|v| !v.is_null()) {
|
||||
given.push(f);
|
||||
}
|
||||
match p.get("features") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(a)) if a.len() <= 10_000 => given.extend(a),
|
||||
Some(Value::Array(_)) => return Err(bad(cmd, "too many features")),
|
||||
Some(one @ (Value::Number(_) | Value::String(_))) => given.push(one),
|
||||
Some(_) => return Err(bad(cmd, "`features` must be a list of feature ids or names")),
|
||||
}
|
||||
if given.is_empty() {
|
||||
return Err(bad(cmd, "`feature` (id or name) or `features` (a list of them) must name a feature"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for v in given {
|
||||
let id = feature_id(s, Some(v), cmd)?;
|
||||
if !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.suppress";
|
||||
let ids = feature_ids(s, p, cmd)?;
|
||||
let want = match bool_(p, "suppressed") {
|
||||
Some(b) => b,
|
||||
None if p.get("suppressed").is_some_and(|v| !v.is_null()) => return Err(bad(cmd, "`suppressed` must be true or false")),
|
||||
// Toggle: one feature flips; several are all suppressed unless all already are.
|
||||
None => !ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed)),
|
||||
};
|
||||
// One command, so one undo step however many features it changes.
|
||||
let doc = s.doc_mut();
|
||||
for id in &ids {
|
||||
let f = doc.feature_mut(*id).ok_or_else(|| bad(cmd, format!("no feature {id}")))?;
|
||||
f.suppressed = want;
|
||||
}
|
||||
match ids.as_slice() {
|
||||
[id] if p.get("features").is_none_or(Value::is_null) => Ok(json!({"feature": id, "suppressed": want})),
|
||||
_ => Ok(json!({"features": ids, "suppressed": want})),
|
||||
}
|
||||
let id = feature_id(s, p.get("feature"), "timeline.suppress")?;
|
||||
let want = bool_(p, "suppressed");
|
||||
let f = s.doc_mut().feature_mut(id).ok_or_else(|| EngineError::Other("feature".into()))?;
|
||||
f.suppressed = want.unwrap_or(!f.suppressed);
|
||||
Ok(json!({"feature": id, "suppressed": f.suppressed}))
|
||||
}
|
||||
|
||||
fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
@@ -687,20 +633,17 @@ fn unknown_field(
|
||||
}
|
||||
|
||||
/// "unknown field `offset`; ConstructionPlane has `plane.base.name`, `plane.distance`…": the
|
||||
/// editable fields where it was given, as paths (each one timeline.edit's `set` takes as a key).
|
||||
fn unknown_field_message(field: &[Step], def: &Value, kind: &FeatureKind) -> String {
|
||||
let type_name = kind.type_name();
|
||||
let heavy = heavy_fields(kind);
|
||||
let skip = |k: &str| NOT_EDITABLE.contains(&k) || heavy.iter().any(|(h, _)| *h == k);
|
||||
fn leaves(v: &Value, prefix: &str, skip: Option<&dyn Fn(&str) -> bool>, depth: usize, out: &mut Vec<String>) {
|
||||
/// editable fields where it was given, as paths.
|
||||
fn unknown_field_message(field: &[Step], def: &Value, type_name: &str) -> String {
|
||||
fn leaves(v: &Value, prefix: &str, top: bool, depth: usize, out: &mut Vec<String>) {
|
||||
match v {
|
||||
Value::Object(o) if depth < 4 && !o.is_empty() => {
|
||||
for (k, x) in o {
|
||||
if skip.is_some_and(|skip| skip(k)) {
|
||||
if top && NOT_EDITABLE.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let at = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
|
||||
leaves(x, &at, None, depth + 1, out);
|
||||
leaves(x, &at, false, depth + 1, out);
|
||||
}
|
||||
}
|
||||
_ if !prefix.is_empty() => out.push(format!("`{prefix}`")),
|
||||
@@ -710,8 +653,7 @@ fn unknown_field_message(field: &[Step], def: &Value, kind: &FeatureKind) -> Str
|
||||
let parent = field.split_last().map(|(_, p)| p).unwrap_or_default();
|
||||
let mut known = Vec::new();
|
||||
if let Some(at) = at_path(def, parent) {
|
||||
let top: Option<&dyn Fn(&str) -> bool> = if parent.is_empty() { Some(&skip) } else { None };
|
||||
leaves(at, &path_text(parent), top, 0, &mut known);
|
||||
leaves(at, &path_text(parent), parent.is_empty(), 0, &mut known);
|
||||
}
|
||||
const SHOWN: usize = 40;
|
||||
let more = known.len().saturating_sub(SHOWN);
|
||||
@@ -727,140 +669,6 @@ fn unknown_field_message(field: &[Step], def: &Value, kind: &FeatureKind) -> Str
|
||||
format!("unknown field `{field}`; {type_name} has {list} (timeline.inspect shows them with their values)")
|
||||
}
|
||||
|
||||
/// `set` with dotted keys (`"plane.distance"`, as timeline.edit's unknown-field message lists the
|
||||
/// fields) expanded into the nested form (`{"plane": {"distance": …}}`), merged with any nested
|
||||
/// form given next to them. A key the definition itself has at that level stays as it is. A field
|
||||
/// given twice (`plane.distance` and `plane: {distance}`) is refused.
|
||||
fn expand_dotted(set: &Value, def: Option<&Value>, cmd: &str, depth: usize) -> Result<Value> {
|
||||
if depth > 16 {
|
||||
return Ok(set.clone());
|
||||
}
|
||||
match set {
|
||||
Value::Object(o) => {
|
||||
let mut nested = serde_json::Map::new();
|
||||
for (k, v) in o {
|
||||
let parts: Vec<&str> = k.split('.').collect();
|
||||
let literal = parts.len() == 1 || parts.len() > 16 || parts.iter().any(|x| x.is_empty()) || def.and_then(|d| d.get(k)).is_some();
|
||||
let parts = if literal { vec![k.as_str()] } else { parts };
|
||||
insert_field(&mut nested, &parts, v.clone(), k, cmd)?;
|
||||
}
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in nested {
|
||||
let d = def.and_then(|d| d.get(&k));
|
||||
out.insert(k, expand_dotted(&v, d, cmd, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| expand_dotted(v, def.and_then(|d| d.get(i)), cmd, depth + 1))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Value::Array),
|
||||
_ => Ok(set.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `v` at `parts` in `out`, creating the objects on the way; `key` is the field as given.
|
||||
fn insert_field(out: &mut serde_json::Map<String, Value>, parts: &[&str], v: Value, key: &str, cmd: &str) -> Result<()> {
|
||||
let twice = || bad(cmd, format!("`{key}` is given twice (as a dotted key and in the nested form)"));
|
||||
let Some((last, parents)) = parts.split_last() else { return Ok(()) };
|
||||
let mut at = out;
|
||||
for part in parents {
|
||||
let slot = at.entry(part.to_string()).or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
at = slot.as_object_mut().ok_or_else(twice)?;
|
||||
}
|
||||
match (at.get_mut(*last), v) {
|
||||
(None, v) => {
|
||||
at.insert(last.to_string(), v);
|
||||
}
|
||||
(Some(Value::Object(slot)), Value::Object(more)) => {
|
||||
for (k, x) in more {
|
||||
insert_field(slot, &[k.as_str()], x, key, cmd)?;
|
||||
}
|
||||
}
|
||||
(Some(_), _) => return Err(twice()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn at_path_mut<'a>(v: &'a mut Value, path: &[Step]) -> Option<&'a mut Value> {
|
||||
path.iter().try_fold(v, |v, s| match s {
|
||||
Step::Key(k) => v.get_mut(k),
|
||||
Step::Index(i) => v.get_mut(*i),
|
||||
})
|
||||
}
|
||||
|
||||
/// Paths of the finite numbers in `set` where the definition (`before`) has no number.
|
||||
fn number_leaves(set: &Value, before: &Value, path: &mut Vec<Step>, out: &mut Vec<Vec<Step>>, depth: usize) {
|
||||
if depth > 16 || out.len() >= 1_000 {
|
||||
return;
|
||||
}
|
||||
match set {
|
||||
Value::Object(o) => {
|
||||
for (k, v) in o {
|
||||
path.push(Step::Key(k.clone()));
|
||||
number_leaves(v, before, path, out, depth + 1);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Array(a) => {
|
||||
for (i, v) in a.iter().enumerate().take(10_000) {
|
||||
path.push(Step::Index(i));
|
||||
number_leaves(v, before, path, out, depth + 1);
|
||||
path.pop();
|
||||
}
|
||||
}
|
||||
Value::Number(n) if crate::params::number_text(n).is_some() && !matches!(at_path(before, path), Some(Value::Number(_))) => {
|
||||
out.push(path.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finite numbers given for expression fields become the expression's text (`8` → `"8"`, mm or
|
||||
/// degrees by context), as the feature commands take them (`params::expr`). Which fields are
|
||||
/// expressions is what the feature's own walk says: each number is put in as a marker text, and
|
||||
/// the markers the feature then shows among its expressions are expression fields. Numbers
|
||||
/// anywhere else stay numbers.
|
||||
fn numbers_as_expressions(mut set: Value, before: &Value) -> Value {
|
||||
let mut found = Vec::new();
|
||||
number_leaves(&set, before, &mut Vec::new(), &mut found, 0);
|
||||
if found.is_empty() {
|
||||
return set;
|
||||
}
|
||||
let marker = |i: usize| format!("\u{1}number {i}");
|
||||
// The markers among the feature's expressions with the numbers at `which` marked, or None
|
||||
// when the feature doesn't read that way.
|
||||
let expressions = |which: &[usize]| -> Option<std::collections::BTreeSet<String>> {
|
||||
let mut marked = set.clone();
|
||||
for &i in which {
|
||||
if let Some(slot) = found.get(i).and_then(|path| at_path_mut(&mut marked, path)) {
|
||||
*slot = Value::String(marker(i));
|
||||
}
|
||||
}
|
||||
let mut v = before.clone();
|
||||
merge(&mut v, &marked, 0);
|
||||
let f: Feature = serde_json::from_value(v).ok()?;
|
||||
Some(f.kind.expressions().into_iter().filter(|e| e.starts_with('\u{1}')).map(str::to_string).collect())
|
||||
};
|
||||
let all: Vec<usize> = (0..found.len()).collect();
|
||||
let hits: Vec<usize> = match expressions(&all) {
|
||||
Some(e) => all.into_iter().filter(|i| e.contains(&marker(*i))).collect(),
|
||||
// Some of them are numbers after all: one at a time.
|
||||
None => all.into_iter().take(64).filter(|i| expressions(&[*i]).is_some_and(|e| e.contains(&marker(*i)))).collect(),
|
||||
};
|
||||
for i in hits {
|
||||
if let Some(slot) = found.get(i).and_then(|path| at_path_mut(&mut set, path))
|
||||
&& let Value::Number(n) = slot
|
||||
&& let Some(text) = crate::params::number_text(n)
|
||||
{
|
||||
*slot = Value::String(text);
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// `profiles` (also a loft section's) given as the feature commands take it (`"all"`,
|
||||
/// `[index]`, `[{curves}]`, `[{point}]`...) becomes the stored form; the stored form passes.
|
||||
fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
@@ -888,17 +696,6 @@ fn command_profiles(set: &Value, cmd: &str) -> Result<Value> {
|
||||
/// the name and suppression, which timeline.rename and timeline.suppress change.
|
||||
const NOT_EDITABLE: &[&str] = &["id", "type", "name", "suppressed", "param_names", "edge_refs", "edge_names", "face_names"];
|
||||
|
||||
/// Large payloads that timeline.edit cannot change, left out of a feature's definition: (field,
|
||||
/// what it holds).
|
||||
fn heavy_fields(kind: &FeatureKind) -> &'static [(&'static str, &'static str)] {
|
||||
match kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "timeline.inspect";
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
@@ -909,7 +706,14 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for k in NOT_EDITABLE {
|
||||
o.remove(*k);
|
||||
}
|
||||
for (k, why) in heavy_fields(&f.kind) {
|
||||
// Large payloads that timeline.edit cannot change.
|
||||
let heavy: &[(&str, &str)] = match &f.kind {
|
||||
FeatureKind::Sketch { .. } => &[("sketch", "the sketch geometry (sketch.inspect; edit it with the sketch commands)")],
|
||||
FeatureKind::Import { .. } => &[("step", "the imported STEP text"), ("components", "the imported product tree")],
|
||||
FeatureKind::MeshImport { .. } => &[("meshes", "the imported meshes")],
|
||||
_ => &[],
|
||||
};
|
||||
for (k, why) in heavy {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
}
|
||||
@@ -940,10 +744,8 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?;
|
||||
let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let before = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let set = expand_dotted(set, Some(&before), cmd, 0)?;
|
||||
let set = &numbers_as_expressions(command_profiles(&set, cmd)?, &before);
|
||||
let mut v = before.clone();
|
||||
let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let set = &command_profiles(set, cmd)?;
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
@@ -960,8 +762,9 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// serde drops fields the definition doesn't have without a word: refuse them, or a typo
|
||||
// would report success and change nothing.
|
||||
let after = serde_json::to_value(&nf).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let before = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let Some(field) = unknown_field(set, Some(&before), Some(&after), &v, &mut Vec::new(), 0) {
|
||||
return Err(bad(cmd, unknown_field_message(&field, &before, &f.kind)));
|
||||
return Err(bad(cmd, unknown_field_message(&field, &before, f.kind.type_name())));
|
||||
}
|
||||
if let FeatureKind::BodyTransfer { bodies, from, to, transform } = &nf.kind {
|
||||
solidcraft_doc::validate_body_transfer(&s.doc, &solidcraft_doc::plain(bodies), *from, *to, transform)?;
|
||||
|
||||
@@ -8,28 +8,6 @@ fn run(s: &mut Session, id: &str, params: Value) -> Value {
|
||||
s.execute(id, ¶ms).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
|
||||
}
|
||||
|
||||
/// `timeline.roll_to` takes a number of features or "end"; any other value is refused instead of
|
||||
/// silently moving the marker to the end.
|
||||
#[test]
|
||||
fn roll_to_takes_a_number_or_end_and_refuses_anything_else() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 20, "operation": "join"}));
|
||||
for cmd in ["timeline.roll_to", "timeline.rollback"] {
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": 1}))["marker"], 1);
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": "end"}))["marker"], 2);
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": "1"}))["marker"], 1, "a number in a string is still a number");
|
||||
assert_eq!(run(&mut s, cmd, json!({"position": "END"}))["marker"], 2);
|
||||
run(&mut s, cmd, json!({"position": 1}));
|
||||
for bad in [json!("start"), json!("first"), json!([1]), json!({"n": 1}), json!(true), json!("NaN")] {
|
||||
let e = s.execute(cmd, &json!({"position": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("`position` must be a number") && e.contains("\"end\""), "{bad}: {e}");
|
||||
assert_eq!(s.doc.marker, Some(1), "{bad}: the refused call left the marker where it was");
|
||||
}
|
||||
assert_eq!(run(&mut s, cmd, json!({}))["marker"], 2, "omitted: the end");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g2_fillet_survives_edit_undo_and_reopen() {
|
||||
use solidcraft_doc::{Document, FeatureKind, FilletStyle};
|
||||
@@ -544,133 +522,3 @@ fn edit_refuses_unknown_fields() {
|
||||
let def = run(&mut s, "timeline.inspect", json!({"feature": "Twin"}))["definition"].clone();
|
||||
assert_eq!(def["combine"], json!(true), "{def}");
|
||||
}
|
||||
|
||||
fn suppressed(s: &Session, name: &str) -> bool {
|
||||
s.doc.find_feature(name).unwrap().suppressed
|
||||
}
|
||||
|
||||
/// timeline.suppress takes `features: [..]` as timeline.delete does, as one undo step; it used to
|
||||
/// refuse it ("`feature` must be an id or name").
|
||||
#[test]
|
||||
fn suppress_takes_a_list_of_features_as_one_undo_step() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "name": "C"}));
|
||||
let id = |s: &Session, name: &str| s.doc.find_feature(name).unwrap().id;
|
||||
let undo = s.undo.len();
|
||||
let a = id(&s, "A");
|
||||
let r = run(&mut s, "timeline.suppress", json!({"features": [a, "B"], "suppressed": true}));
|
||||
assert_eq!(r, json!({"features": [id(&s, "A"), id(&s, "B")], "suppressed": true}));
|
||||
assert!(suppressed(&s, "A") && suppressed(&s, "B") && !suppressed(&s, "C"));
|
||||
assert_eq!(s.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!suppressed(&s, "A") && !suppressed(&s, "B"));
|
||||
assert_eq!(s.model.state().bodies.len(), 3);
|
||||
|
||||
// Toggling several: all suppressed unless all already are, then all unsuppressed.
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "A"}));
|
||||
run(&mut s, "timeline.suppress", json!({"features": ["A", "B", "C"]}));
|
||||
assert!(suppressed(&s, "A") && suppressed(&s, "B") && suppressed(&s, "C"));
|
||||
run(&mut s, "timeline.suppress", json!({"features": ["A", "B", "C"]}));
|
||||
assert!(!suppressed(&s, "A") && !suppressed(&s, "B") && !suppressed(&s, "C"));
|
||||
// `feature` and `features` together; a repeat counts once.
|
||||
let r = run(&mut s, "timeline.suppress", json!({"feature": "C", "features": ["C", "B"], "suppressed": true}));
|
||||
assert_eq!(r["features"], json!([id(&s, "C"), id(&s, "B")]), "{r}");
|
||||
assert!(!suppressed(&s, "A") && suppressed(&s, "B") && suppressed(&s, "C"));
|
||||
// A single `feature` replies as before.
|
||||
let r = run(&mut s, "timeline.suppress", json!({"feature": "C", "suppressed": false}));
|
||||
assert_eq!(r, json!({"feature": id(&s, "C"), "suppressed": false}));
|
||||
|
||||
// An unknown feature in the list refuses the whole command, as do no features at all.
|
||||
let before = (*s.doc).clone();
|
||||
let undo = s.undo.len();
|
||||
for bad in [
|
||||
json!({"features": ["A", "Nope"], "suppressed": true}),
|
||||
json!({"features": []}),
|
||||
json!({}),
|
||||
json!({"features": [{}]}),
|
||||
json!({"features": ["A"], "suppressed": "yes"}),
|
||||
] {
|
||||
assert!(s.execute("timeline.suppress", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(*s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
// timeline.delete takes either.
|
||||
run(&mut s, "timeline.delete", json!({"feature": "C"}));
|
||||
run(&mut s, "timeline.delete", json!({"features": ["B"]}));
|
||||
assert!(s.doc.find_feature("B").is_none() && s.doc.find_feature("C").is_none());
|
||||
}
|
||||
|
||||
/// The value at a dotted path of a definition.
|
||||
fn dotted<'a>(def: &'a Value, path: &str) -> &'a Value {
|
||||
path.split('.').fold(def, |v, k| &v[k])
|
||||
}
|
||||
|
||||
/// The lowest Z of the design's first body.
|
||||
fn low_z(s: &mut Session) -> f64 {
|
||||
let m = run(s, "inspect.measure", json!({}));
|
||||
let min = &m["bodies"][0]["bbox"]["min"];
|
||||
min[2].as_f64().or_else(|| min["z"].as_f64()).unwrap_or_else(|| panic!("{m}"))
|
||||
}
|
||||
|
||||
/// timeline.edit takes the dotted keys its unknown-field message lists (`plane.distance`), and
|
||||
/// numbers for expressions, as the feature commands do; both used to be refused.
|
||||
#[test]
|
||||
fn edit_takes_dotted_keys_and_numbers_for_expressions() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": 5, "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Pad"}));
|
||||
let def = |s: &mut Session, f: &str| run(s, "timeline.inspect", json!({"feature": f}))["definition"].clone();
|
||||
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane.distance": "8 mm"}}));
|
||||
assert_eq!(def(&mut s, "S")["plane"]["distance"], json!("8 mm"));
|
||||
assert!((low_z(&mut s) - 8.0).abs() < 1e-9);
|
||||
// A bare number is mm, as in sketch.create's `offset`.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane.distance": 12}}));
|
||||
assert_eq!(def(&mut s, "S")["plane"]["distance"], json!("12"));
|
||||
assert!((low_z(&mut s) - 12.0).abs() < 1e-9);
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane": {"distance": 3.5}}}));
|
||||
assert_eq!(def(&mut s, "S")["plane"]["distance"], json!("3.5"));
|
||||
// The nested form works as before.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {"plane": {"distance": "4"}}}));
|
||||
assert!((low_z(&mut s) - 4.0).abs() < 1e-9);
|
||||
|
||||
// Every field the unknown-field message lists is accepted as it is written there.
|
||||
let e = s.execute("timeline.edit", &json!({"feature": "S", "set": {"plane.dist": "1"}})).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown field `plane.dist`") && e.contains("`plane.distance`"), "{e}");
|
||||
let e = s.execute("timeline.edit", &json!({"feature": "S", "set": {"nope": "1"}})).unwrap_err().to_string();
|
||||
let listed: Vec<&str> = e.split('`').skip(3).step_by(2).collect();
|
||||
assert!(listed.contains(&"plane.distance") && listed.contains(&"plane.type"), "{e}");
|
||||
// Not the sketch geometry, which timeline.inspect leaves out and the sketch commands edit.
|
||||
assert!(!listed.iter().any(|k| k.starts_with("sketch")), "{e}");
|
||||
let now = def(&mut s, "S");
|
||||
for key in listed {
|
||||
let value = dotted(&now, key).clone();
|
||||
run(&mut s, "timeline.edit", json!({"feature": "S", "set": {key: value}}));
|
||||
}
|
||||
assert_eq!(def(&mut s, "S"), now);
|
||||
|
||||
// Numbers on an extrude, also for an expression the definition leaves out.
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Pad", "set": {"extent.distance": 20, "extent": {"taper": 0}}}));
|
||||
let d = def(&mut s, "Pad");
|
||||
assert_eq!((&d["extent"]["distance"], &d["extent"]["taper"]), (&json!("20"), &json!("0")), "{d}");
|
||||
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
|
||||
assert!((volume - 2000.0).abs() < 1e-6, "{volume}");
|
||||
|
||||
// Still refused: a field given twice, unknown dotted fields, a number for a name.
|
||||
let before = (*s.doc).clone();
|
||||
for (set, says) in [
|
||||
(json!({"plane.distance": "1", "plane": {"distance": "2"}}), "given twice"),
|
||||
(json!({"plane.wobble": "1"}), "unknown field `plane.wobble`"),
|
||||
(json!({"plane.distance.x": "1"}), "invalid feature"),
|
||||
(json!({"plane.base.name": 5}), "invalid feature"),
|
||||
] {
|
||||
let e = s.execute("timeline.edit", &json!({"feature": "S", "set": set})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{set}: {e}");
|
||||
}
|
||||
assert_eq!(*s.doc, before);
|
||||
}
|
||||
|
||||
@@ -1,439 +0,0 @@
|
||||
//! Interference (`inspect.interference`): the pairs of bodies that share volume, and how much.
|
||||
//!
|
||||
//! Pairs are pruned in three steps before anything exact runs. Their boxes first (sort and
|
||||
//! sweep), then their display meshes: a mesh lies within its chord tolerance of the faces, so
|
||||
//! two meshes farther apart than a margin over both tolerances belong to bodies whose boundaries
|
||||
//! don't meet, and unless one body sits inside the other (a point of each face of one tested
|
||||
//! against the other's mesh) the pair is clear without a boolean. The pairs left are intersected
|
||||
//! exactly and measured, in parallel on desktop (one after another in the browser), the slowest
|
||||
//! first, under a time budget: pairs not finished when it runs out are listed as not checked
|
||||
//! rather than holding the call. The shared part is measured as `solidcraft_kernel::measure`
|
||||
//! measures it (its mass properties alone).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::mesh_distance::TriangleSet;
|
||||
use solidcraft_geom::{Aabb3, Mesh, Vec3};
|
||||
use solidcraft_kernel::Body;
|
||||
use solidcraft_kernel::profile::KernelTimes;
|
||||
|
||||
use crate::params::{bad, num, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Most pairs with overlapping bounding boxes one call intersects.
|
||||
pub(crate) const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
/// The time budget when the call gives none (s): under the 300 s an MCP client waits for a tool.
|
||||
pub(crate) const DEFAULT_BUDGET_S: f64 = 240.0;
|
||||
/// The longest time budget a call may ask for (s).
|
||||
const MAX_BUDGET_S: f64 = 86_400.0;
|
||||
/// How many chord tolerances apart (of both meshes together) two meshes must be to clear a pair
|
||||
/// without intersecting it: twice what the tolerances allow, for meshers that sag a little more.
|
||||
const MESH_MARGIN: f64 = 4.0;
|
||||
/// Most pairs listed under `slowest`.
|
||||
const SLOWEST: usize = 8;
|
||||
|
||||
/// One pair to intersect exactly: the bodies (cheap clones sharing the solids) and their names.
|
||||
struct Job {
|
||||
pair: usize,
|
||||
a: Body,
|
||||
b: Body,
|
||||
names: (String, String),
|
||||
/// Faces of one times faces of the other: the slowest pairs start first.
|
||||
cost: usize,
|
||||
}
|
||||
|
||||
/// What an exact check of one pair came to.
|
||||
enum Outcome {
|
||||
Hit(Value),
|
||||
Clear,
|
||||
Unchecked(String),
|
||||
}
|
||||
|
||||
/// One pair's answer and where its time went.
|
||||
struct Done {
|
||||
outcome: Outcome,
|
||||
/// Unverified boolean results are listed as unchecked and still reported as a hit.
|
||||
note: Option<String>,
|
||||
ms: f64,
|
||||
kernel: KernelTimes,
|
||||
}
|
||||
|
||||
pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "inspect.interference";
|
||||
let started = web_time::Instant::now();
|
||||
let budget_s = match p.get("budget_s") {
|
||||
None | Some(Value::Null) => DEFAULT_BUDGET_S,
|
||||
Some(_) => match num(p, "budget_s") {
|
||||
Some(v) if v > 0.0 && v <= MAX_BUDGET_S => v,
|
||||
_ => return Err(bad(cmd, format!("`budget_s` must be a number of seconds above 0, at most {MAX_BUDGET_S}"))),
|
||||
},
|
||||
};
|
||||
let budget = std::time::Duration::from_secs_f64(budget_s);
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
|
||||
// The bodies' display meshes, made once (in parallel) and kept on the bodies: a design open
|
||||
// in the app has them already.
|
||||
let meshes: Vec<Arc<Mesh>> = {
|
||||
let made = solidcraft_kernel::map_with_local_pools(&bodies, usize::MAX, |b| Ok(b.mesh()));
|
||||
made.into_iter().map(|m| m.unwrap_or_default()).collect()
|
||||
};
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
// Each mesh lies within its chord of the faces, inside a round one: grown by that, the boxes
|
||||
// of bodies a hundredth of a millimetre into each other still meet (an arm's round end into a
|
||||
// rounded rim, its facets clear of the rim's).
|
||||
let chords: Vec<f64> = bodies.iter().map(|b| b.display_tol()).collect();
|
||||
let boxes: Vec<Aabb3> = meshes
|
||||
.iter()
|
||||
.zip(&chords)
|
||||
.map(|(m, &chord)| {
|
||||
let (bx, g) = (m.bounds(), 2.0 * chord);
|
||||
if bx.is_empty() { bx } else { Aabb3 { min: bx.min - Vec3::new(g, g, g), max: bx.max + Vec3::new(g, g, g) } }
|
||||
})
|
||||
.collect();
|
||||
let (pairs, skipped) = super::joints::overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
|
||||
// Then by the meshes: pairs whose meshes stay apart (and neither holds the other) are clear.
|
||||
let used: Vec<bool> = {
|
||||
let mut u = vec![false; bodies.len()];
|
||||
for &(i, j) in &pairs {
|
||||
for k in [i, j] {
|
||||
if let Some(x) = u.get_mut(k) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
u
|
||||
};
|
||||
let sets: Vec<Option<TriangleSet>> = {
|
||||
let idx: Vec<usize> = (0..bodies.len()).filter(|&i| used.get(i).copied().unwrap_or(false)).collect();
|
||||
let made = solidcraft_kernel::map_with_local_pools(&idx, usize::MAX, |&i| Ok(meshes.get(i).map(|m| TriangleSet::new(m))));
|
||||
let mut out: Vec<Option<TriangleSet>> = std::iter::repeat_with(|| None).take(bodies.len()).collect();
|
||||
for (&i, set) in idx.iter().zip(made) {
|
||||
if let (Some(slot), Ok(set)) = (out.get_mut(i), set) {
|
||||
*slot = set;
|
||||
}
|
||||
}
|
||||
out
|
||||
};
|
||||
let need_exact = solidcraft_kernel::map_with_local_pools(&pairs, usize::MAX, |&(i, j)| {
|
||||
let (Some(Some(sa)), Some(Some(sb)), Some(ma), Some(mb)) = (sets.get(i), sets.get(j), meshes.get(i), meshes.get(j)) else {
|
||||
return Ok(true);
|
||||
};
|
||||
let margin = MESH_MARGIN * (chords.get(i).copied().unwrap_or(0.0) + chords.get(j).copied().unwrap_or(0.0));
|
||||
Ok(!meshes_apart(ma, sa, mb, sb, margin))
|
||||
});
|
||||
let mut jobs = Vec::new();
|
||||
let mut cleared = 0usize;
|
||||
for (k, (&(i, j), exact)) in pairs.iter().zip(need_exact).enumerate() {
|
||||
if !exact.unwrap_or(true) {
|
||||
cleared += 1;
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
jobs.push(Job {
|
||||
pair: k,
|
||||
a: a.body.clone(),
|
||||
b: b.body.clone(),
|
||||
names: (a.name.clone(), b.name.clone()),
|
||||
cost: a.body.face_count().saturating_mul(b.body.face_count()),
|
||||
});
|
||||
}
|
||||
let intersected = jobs.len();
|
||||
jobs.sort_by(|x, y| y.cost.cmp(&x.cost).then(x.pair.cmp(&y.pair)));
|
||||
|
||||
let left = budget.saturating_sub(started.elapsed());
|
||||
let waited = web_time::Instant::now();
|
||||
let (done, threads) = run_jobs(jobs, left);
|
||||
let waited_ms = waited.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let (mut hits, mut unchecked, mut timed) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let (mut not_checked, mut boolean_ms, mut tessellate_ms) = (0usize, 0.0, 0.0);
|
||||
for ((a, b), d) in done {
|
||||
let Some(d) = d else {
|
||||
not_checked += 1;
|
||||
unchecked.push(json!({"a": a, "b": b, "reason": format!("not checked: the time budget of {budget_s} s ran out (budget_s)")}));
|
||||
continue;
|
||||
};
|
||||
boolean_ms += d.kernel.boolean_ms;
|
||||
tessellate_ms += d.kernel.tessellate_ms;
|
||||
timed.push((
|
||||
d.ms,
|
||||
json!({"a": a, "b": b, "ms": round_ms(d.ms), "boolean_ms": round_ms(d.kernel.boolean_ms),
|
||||
"tessellate_ms": round_ms(d.kernel.tessellate_ms)}),
|
||||
));
|
||||
if let Some(reason) = d.note {
|
||||
unchecked.push(json!({"a": a, "b": b, "reason": reason}));
|
||||
}
|
||||
match d.outcome {
|
||||
Outcome::Hit(v) => hits.push(v),
|
||||
Outcome::Clear => {}
|
||||
Outcome::Unchecked(reason) => unchecked.push(json!({"a": a, "b": b, "reason": reason})),
|
||||
}
|
||||
}
|
||||
// A timing profile of the command (`--profile`) counts the kernel time the workers spent,
|
||||
// shared out over the time this thread waited for them (on the calling thread it is counted
|
||||
// already).
|
||||
if threads > 0 && boolean_ms + tessellate_ms > 0.0 {
|
||||
let share = (waited_ms / (boolean_ms + tessellate_ms)).min(1.0);
|
||||
solidcraft_kernel::profile::credit(KernelTimes { boolean_ms: boolean_ms * share, tessellate_ms: tessellate_ms * share });
|
||||
}
|
||||
timed.sort_by(|x, y| y.0.total_cmp(&x.0));
|
||||
let slowest: Vec<Value> = timed.into_iter().take(SLOWEST).map(|t| t.1).collect();
|
||||
Ok(json!({
|
||||
"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped,
|
||||
"pairs_candidate": pairs.len(), "pairs_cleared": cleared, "pairs_intersected": intersected,
|
||||
"pairs_not_checked": not_checked, "budget_s": budget_s, "budget_exceeded": not_checked > 0,
|
||||
"threads": threads, "boolean_ms": round_ms(boolean_ms), "tessellate_ms": round_ms(tessellate_ms),
|
||||
"slowest": slowest, "ms": round_ms(started.elapsed().as_secs_f64() * 1000.0),
|
||||
}))
|
||||
}
|
||||
|
||||
fn round_ms(ms: f64) -> f64 {
|
||||
(ms * 1000.0).round() / 1000.0
|
||||
}
|
||||
|
||||
/// Are two bodies' meshes farther apart than `margin` with neither body inside the other? Then
|
||||
/// their boundaries don't meet (each mesh is within its chord tolerance of its faces, which the
|
||||
/// margin covers), so the bodies share no volume. Conservative: an empty mesh, nested boxes or
|
||||
/// a point of one inside the other answer no.
|
||||
fn meshes_apart(ma: &Mesh, sa: &TriangleSet, mb: &Mesh, sb: &TriangleSet, margin: f64) -> bool {
|
||||
if ma.triangles.is_empty() || mb.triangles.is_empty() || !margin.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if sa.closest(sb, margin).is_some() {
|
||||
return false;
|
||||
}
|
||||
// Apart boundaries: either the bodies are apart, or one holds the other (or a piece of it).
|
||||
let (ba, bb) = (ma.bounds(), mb.bounds());
|
||||
if nested(&ba, &bb) || nested(&bb, &ba) {
|
||||
return false;
|
||||
}
|
||||
!(any_inside(&face_points(ma), mb) || any_inside(&face_points(mb), ma))
|
||||
}
|
||||
|
||||
/// Is `inner` inside `outer`?
|
||||
fn nested(inner: &Aabb3, outer: &Aabb3) -> bool {
|
||||
!inner.is_empty()
|
||||
&& outer.min.x <= inner.min.x
|
||||
&& outer.min.y <= inner.min.y
|
||||
&& outer.min.z <= inner.min.z
|
||||
&& inner.max.x <= outer.max.x
|
||||
&& inner.max.y <= outer.max.y
|
||||
&& inner.max.z <= outer.max.z
|
||||
}
|
||||
|
||||
/// A corner of each face's first triangle (every piece of a body in several pieces has a face).
|
||||
fn face_points(m: &Mesh) -> Vec<Vec3> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (k, t) in m.triangles.iter().enumerate() {
|
||||
let face = m.tri_face.get(k).copied().unwrap_or(0);
|
||||
if seen.insert(face)
|
||||
&& let Some(p) = m.positions.get(t[0] as usize)
|
||||
{
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is any of `points` inside the closed mesh `outer`?
|
||||
fn any_inside(points: &[Vec3], outer: &Mesh) -> bool {
|
||||
let b = outer.bounds();
|
||||
let within = |p: &&Vec3| b.min.x <= p.x && p.x <= b.max.x && b.min.y <= p.y && p.y <= b.max.y && b.min.z <= p.z && p.z <= b.max.z;
|
||||
let candidates: Vec<&Vec3> = points.iter().filter(within).collect();
|
||||
match candidates.len() {
|
||||
0 => false,
|
||||
1..=4 => candidates.iter().any(|p| outer.contains(**p)),
|
||||
_ => {
|
||||
let index = outer.inside_index();
|
||||
candidates.iter().any(|p| index.contains(**p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exact check of one pair: a pin a hair off the axis of its hole by the walls' crescent,
|
||||
/// otherwise the intersection measured.
|
||||
fn check(job: &Job) -> Done {
|
||||
let started = web_time::Instant::now();
|
||||
let ((outcome, note), kernel) = solidcraft_kernel::profile::capture(|| check_pair(&job.a, &job.b, &job.names));
|
||||
Done { outcome, note, ms: started.elapsed().as_secs_f64() * 1000.0, kernel }
|
||||
}
|
||||
|
||||
fn check_pair(a: &Body, b: &Body, (na, nb): &(String, String)) -> (Outcome, Option<String>) {
|
||||
let threshold = 1e-9 * a.size().max(1.0).powi(3);
|
||||
// A pin a hair off the axis of its hole: the crescent the walls share, which the boolean
|
||||
// can't intersect.
|
||||
if let Some(o) = solidcraft_kernel::coaxial_overlap(a, b) {
|
||||
if o.volume > threshold {
|
||||
let hit = json!({"a": na, "b": nb, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid,
|
||||
"bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}});
|
||||
return (Outcome::Hit(hit), None);
|
||||
}
|
||||
return (Outcome::Clear, None);
|
||||
}
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Intersect));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => return (Outcome::Unchecked(e.to_string()), None),
|
||||
};
|
||||
let mut note = report.unverified();
|
||||
let pieces = match shared {
|
||||
Some(o) => vec![o],
|
||||
// A body in several pieces, one of them wholly inside the other body: the whole bodies'
|
||||
// boolean can come out empty, so the pieces are intersected one by one.
|
||||
None if a.shell_count() > 1 || b.shell_count() > 1 => match by_pieces(a, b) {
|
||||
Ok((pieces, unverified)) => {
|
||||
note = note.or(unverified);
|
||||
pieces
|
||||
}
|
||||
Err(e) => return (Outcome::Unchecked(e), note),
|
||||
},
|
||||
None => return (Outcome::Clear, note),
|
||||
};
|
||||
// The shared parts measured together: volumes and areas add, the centre is the volumes'.
|
||||
let mut measured = Vec::new();
|
||||
for o in &pieces {
|
||||
match solidcraft_kernel::measure_mass(o) {
|
||||
Ok(m) => measured.push(m),
|
||||
Err(e) => return (Outcome::Unchecked(e.to_string()), note),
|
||||
}
|
||||
}
|
||||
let volume: f64 = measured.iter().map(|m| m.volume).sum();
|
||||
if volume > threshold {
|
||||
let area: f64 = measured.iter().map(|m| m.area).sum();
|
||||
let bbox = measured.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bbox));
|
||||
let center = match measured.as_slice() {
|
||||
[m] => m.centroid,
|
||||
_ => measured.iter().fold(Vec3::ZERO, |c, m| c + m.centroid * m.volume) / volume,
|
||||
};
|
||||
let hit = json!({"a": na, "b": nb, "volume_mm3": volume, "area_mm2": area, "center": center, "bbox": {"min": bbox.min, "max": bbox.max}});
|
||||
return (Outcome::Hit(hit), note);
|
||||
}
|
||||
(Outcome::Clear, note)
|
||||
}
|
||||
|
||||
/// Most pairs of pieces [`by_pieces`] intersects.
|
||||
const MAX_PIECE_PAIRS: usize = 256;
|
||||
|
||||
/// The shared parts of two bodies, a piece of each at a time, and why any of them is unverified.
|
||||
fn by_pieces(a: &Body, b: &Body) -> std::result::Result<(Vec<Body>, Option<String>), String> {
|
||||
let (la, lb) = (a.lumps().map_err(|e| e.to_string())?, b.lumps().map_err(|e| e.to_string())?);
|
||||
if la.len() < 2 && lb.len() < 2 {
|
||||
return Ok((Vec::new(), None));
|
||||
}
|
||||
if la.len().saturating_mul(lb.len()) > MAX_PIECE_PAIRS {
|
||||
return Err(format!("not checked: {} by {} pieces are more than {MAX_PIECE_PAIRS} pairs of pieces", la.len(), lb.len()));
|
||||
}
|
||||
let (mut out, mut note) = (Vec::new(), None);
|
||||
for x in &la {
|
||||
for y in &lb {
|
||||
let (r, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(x, y, solidcraft_kernel::BoolOp::Intersect));
|
||||
note = note.or(report.unverified());
|
||||
if let Some(o) = r.map_err(|e| e.to_string())? {
|
||||
out.push(o);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((out, note))
|
||||
}
|
||||
|
||||
/// Each job's answer (in pair order, with the pair's names; `None`: not finished within
|
||||
/// `budget`) and how many worker threads ran them (0: this thread did).
|
||||
fn run_jobs(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<((String, String), Option<Done>)>, usize) {
|
||||
let mut order: Vec<(usize, usize, (String, String))> = jobs.iter().enumerate().map(|(k, j)| (j.pair, k, j.names.clone())).collect();
|
||||
order.sort_unstable_by_key(|o| o.0);
|
||||
let (mut results, threads) = run_ordered(jobs, budget);
|
||||
let out = order.into_iter().map(|(_, k, names)| (names, results.get_mut(k).and_then(Option::take))).collect();
|
||||
(out, threads)
|
||||
}
|
||||
|
||||
/// Each job's answer in job order, and the threads used. On desktop the jobs run on worker
|
||||
/// threads of their own, which the call does not wait for once `budget` is spent: a pair still
|
||||
/// running then finishes in the background and is dropped (a kernel boolean can't be stopped
|
||||
/// part way).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_ordered(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<Option<Done>>, usize) {
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
|
||||
let n = jobs.len();
|
||||
let mut out: Vec<Option<Done>> = std::iter::repeat_with(|| None).take(n).collect();
|
||||
if n == 0 || budget.is_zero() {
|
||||
return (out, 0);
|
||||
}
|
||||
let deadline = web_time::Instant::now() + budget;
|
||||
// The workers outlive the call when the budget runs out, so they own what they work on.
|
||||
let jobs = Arc::new(jobs);
|
||||
let next = Arc::new(AtomicUsize::new(0));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let (tx, rx) = mpsc::channel::<(usize, Done)>();
|
||||
let want = std::thread::available_parallelism().map(usize::from).unwrap_or(1).min(n).max(1);
|
||||
let mut threads = 0;
|
||||
for _ in 0..want {
|
||||
let (jobs, next, stop, tx) = (jobs.clone(), next.clone(), stop.clone(), tx.clone());
|
||||
let spawned = std::thread::Builder::new().name("solidcraft-interference".into()).spawn(move || {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let k = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(job) = jobs.get(k) else { break };
|
||||
let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check(job))).unwrap_or_else(|_| Done {
|
||||
outcome: Outcome::Unchecked("internal error while checking this pair".into()),
|
||||
note: None,
|
||||
ms: 0.0,
|
||||
kernel: KernelTimes::default(),
|
||||
});
|
||||
if tx.send((k, done)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
threads += 1;
|
||||
}
|
||||
}
|
||||
drop(tx);
|
||||
if threads == 0 {
|
||||
// No thread could be started: one pair after another here.
|
||||
return (run_here(&jobs, deadline), 0);
|
||||
}
|
||||
let mut received = 0;
|
||||
while received < n {
|
||||
let left = deadline.saturating_duration_since(web_time::Instant::now());
|
||||
match rx.recv_timeout(left) {
|
||||
Ok((k, done)) => {
|
||||
if let Some(slot) = out.get_mut(k) {
|
||||
*slot = Some(done);
|
||||
}
|
||||
received += 1;
|
||||
}
|
||||
// Out of time, or every worker gone (none is left with work then).
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
(out, threads)
|
||||
}
|
||||
|
||||
/// The browser has no threads: one pair after another, none started once the budget is spent.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn run_ordered(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<Option<Done>>, usize) {
|
||||
(run_here(&jobs, web_time::Instant::now() + budget), 0)
|
||||
}
|
||||
|
||||
/// The jobs one after another on this thread, none started past `deadline`.
|
||||
fn run_here(jobs: &[Job], deadline: web_time::Instant) -> Vec<Option<Done>> {
|
||||
jobs.iter().map(|job| (web_time::Instant::now() < deadline).then(|| check(job))).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "interference_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,110 +0,0 @@
|
||||
//! `inspect.interference`: the mesh pre-filter, the bodies it must not clear, and the budget.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn f(v: &Value) -> f64 {
|
||||
v.as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Curved bodies whose boxes overlap but which stay apart are answered from their meshes: no
|
||||
/// boolean runs. Two cylinders on a diagonal (centres 12.7 mm apart, radii 5), and a torus
|
||||
/// beside a sphere that sits in the corner of its box.
|
||||
#[test]
|
||||
fn separated_curved_bodies_are_cleared_without_a_boolean() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [0, 0, 0], "body_name": "C1"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [9, 9, 0], "operation": "new", "body_name": "C2"}));
|
||||
run(&mut s, "solid.torus", json!({"major": 20, "minor": 3, "center": [60, 0, 0], "body_name": "Torus"}));
|
||||
// In the torus box's corner (the box reaches 23 out on each axis), 1.66 mm clear of the tube.
|
||||
run(&mut s, "solid.sphere", json!({"radius": 3, "center": [80, 19, 1], "operation": "new", "body_name": "Ball"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
assert_eq!(i["pairs_candidate"], 2, "{i}");
|
||||
assert_eq!(i["pairs_cleared"], 2, "{i}");
|
||||
assert_eq!(i["pairs_intersected"], 0, "{i}");
|
||||
assert_eq!(f(&i["boolean_ms"]), 0.0, "{i}");
|
||||
assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}");
|
||||
// Moved into each other, the same pairs are intersected and overlap.
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "base": [7, 0, 0], "operation": "new", "body_name": "C3"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({"bodies": ["C1", "C3"]}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert_eq!(i["pairs_intersected"], 1, "{i}");
|
||||
// The lens of two r5 circles 7 apart, 10 high.
|
||||
let (r, d): (f64, f64) = (5.0, 7.0);
|
||||
let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt();
|
||||
let got = f(&i["interferences"][0]["volume_mm3"]);
|
||||
assert!((got - 10.0 * lens).abs() < 1e-3 * 10.0 * lens, "{got} against {}: {i}", 10.0 * lens);
|
||||
}
|
||||
|
||||
/// A body wholly inside another has meshes that never meet: it is still intersected, and its
|
||||
/// whole volume is the overlap.
|
||||
#[test]
|
||||
fn a_body_inside_another_is_still_found() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 40, "corner": [-20, -20, -20], "body_name": "Block"}));
|
||||
run(&mut s, "solid.sphere", json!({"radius": 5, "center": [3, -2, 1], "operation": "new", "body_name": "Ball"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert_eq!(i["pairs_cleared"], 0, "{i}");
|
||||
let want = 4.0 / 3.0 * std::f64::consts::PI * 125.0;
|
||||
let got = f(&i["interferences"][0]["volume_mm3"]);
|
||||
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}: {i}");
|
||||
}
|
||||
|
||||
/// A body in two pieces, one of them wholly inside a block and the other outside it: the boxes
|
||||
/// are not nested and the meshes never meet, but a point of the inner piece's faces is inside
|
||||
/// the block, so the pair is intersected. The whole bodies' boolean comes out empty there, so
|
||||
/// the pieces are intersected one by one.
|
||||
#[test]
|
||||
fn a_piece_of_a_body_inside_another_is_still_found() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [0, 0, 0], "body_name": "Block"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [5, 5, 5], "operation": "new", "body_name": "Pieces"}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": [30, 5, 5], "operation": "join", "targets": ["Pieces"]}));
|
||||
let st = s.world_state();
|
||||
let pieces = st.bodies.iter().find(|b| b.name == "Pieces").map(|b| b.body.shell_count());
|
||||
assert_eq!(pieces, Some(2), "the two cubes are one body in two pieces");
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["count"], 1, "{i}");
|
||||
assert_eq!(i["pairs_cleared"], 0, "{i}");
|
||||
assert!((f(&i["interferences"][0]["volume_mm3"]) - 8.0).abs() < 1e-6, "{i}");
|
||||
}
|
||||
|
||||
/// A budget spent before any pair could start lists every pair as not checked (and the call
|
||||
/// returns); bad budgets are refused.
|
||||
#[test]
|
||||
fn pairs_left_when_the_budget_runs_out_are_listed_not_checked() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, 0], "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [5, 0, 0], "operation": "new", "body_name": "B"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [12, 0, 0], "operation": "new", "body_name": "C"}));
|
||||
let i = run(&mut s, "inspect.interference", json!({"budget_s": 1e-12}));
|
||||
assert_eq!(i["count"], 0, "{i}");
|
||||
assert_eq!(i["budget_exceeded"], true, "{i}");
|
||||
assert_eq!(i["pairs_not_checked"], 2, "{i}");
|
||||
let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(unchecked.len(), 2, "{i}");
|
||||
assert!(unchecked.iter().all(|u| u["reason"].as_str().is_some_and(|r| r.starts_with("not checked") && r.contains("time budget"))), "{i}");
|
||||
// The default budget checks both pairs: 500 and 300 mm³.
|
||||
let i = run(&mut s, "inspect.interference", json!({}));
|
||||
assert_eq!(i["budget_exceeded"], false, "{i}");
|
||||
assert_eq!(i["budget_s"], 240.0, "{i}");
|
||||
assert_eq!(i["count"], 2, "{i}");
|
||||
let mut v: Vec<f64> = i["interferences"].as_array().into_iter().flatten().map(|h| f(&h["volume_mm3"])).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
assert!((v[0] - 300.0).abs() < 1e-6 && (v[1] - 500.0).abs() < 1e-6, "{i}");
|
||||
// Answers come in pair order whatever order they finished in.
|
||||
assert_eq!((i["interferences"][0]["a"].as_str(), i["interferences"][0]["b"].as_str()), (Some("A"), Some("B")), "{i}");
|
||||
for bad in [json!(0), json!(-1), json!("soon"), json!(f64::MAX), json!(1e6)] {
|
||||
assert!(s.execute("inspect.interference", &json!({"budget_s": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use solidcraft_doc::joints::{self, Coupling, Joint, JointExprs, JointKind, Joint
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, num, str_, vec3};
|
||||
use crate::params::{bad, bool_, num, str_, string_list, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -77,8 +77,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
|
||||
),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", super::interference::interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms",
|
||||
CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
"bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre (a pin a hair off the axis of a hole of its size also has near_coaxial: {offset_mm, length_mm}, its volume the crescent the walls share); unchecked: pairs that could not be checked, with the reason. Bodies are pruned by bounding box first, so any number of bodies is fine; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out)",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1320,6 +1320,61 @@ fn solve_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(sol_json(&sol, p))
|
||||
}
|
||||
|
||||
fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let want = string_list(p, "bodies");
|
||||
let st = s.world_state();
|
||||
let bodies: Vec<&solidcraft_doc::ModelBody> =
|
||||
st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).filter(|b| !b.body.is_mesh()).collect();
|
||||
// Prune by bounding boxes first (sort and sweep), so the cost follows the pairs that can
|
||||
// touch rather than the square of the body count.
|
||||
let boxes: Vec<Aabb3> = bodies.iter().map(|b| b.mesh().bounds()).collect();
|
||||
let (pairs, skipped) = overlapping_pairs(&boxes, MAX_INTERFERENCE_PAIRS);
|
||||
let (mut hits, mut unchecked) = (Vec::new(), Vec::new());
|
||||
for (i, j) in pairs {
|
||||
let (Some(a), Some(b)) = (bodies.get(i), bodies.get(j)) else { continue };
|
||||
let threshold = 1e-9 * a.body.size().max(1.0).powi(3);
|
||||
// A pin a hair off the axis of its hole: the crescent the walls share, which the
|
||||
// boolean can't intersect.
|
||||
if let Some(o) = solidcraft_kernel::coaxial_overlap(&a.body, &b.body) {
|
||||
if o.volume > threshold {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": o.volume, "area_mm2": o.area, "center": o.centroid,
|
||||
"bbox": {"min": o.bbox.min, "max": o.bbox.max}, "near_coaxial": {"offset_mm": o.offset, "length_mm": o.length}}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A pair whose shared part can't be worked out is reported, not passed as clear.
|
||||
let (shared, report) =
|
||||
solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect));
|
||||
let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason}));
|
||||
let shared = match shared {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(reason) = report.unverified() {
|
||||
skip(reason);
|
||||
}
|
||||
let Some(o) = shared else { continue };
|
||||
let m = match solidcraft_kernel::measure(&o) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
skip(e.to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if m.volume > threshold {
|
||||
hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}}));
|
||||
}
|
||||
}
|
||||
Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped}))
|
||||
}
|
||||
|
||||
/// Most pairs with overlapping bounding boxes `inspect.interference` intersects in one call.
|
||||
const MAX_INTERFERENCE_PAIRS: usize = 20_000;
|
||||
|
||||
/// Index pairs `(i, j)`, `i < j`, whose boxes overlap or touch (sort and sweep along X), at most
|
||||
/// `limit` of them, plus how many more there were.
|
||||
pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, usize)>, usize) {
|
||||
|
||||
@@ -39,7 +39,6 @@ mod component_link;
|
||||
mod config;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod interference;
|
||||
pub(crate) mod joints;
|
||||
mod libraries;
|
||||
mod material_properties;
|
||||
|
||||
@@ -521,31 +521,3 @@ fn pattern_of_cuts_beside_a_round_needs_no_retries() {
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, expected) < 1e-5, "{v} vs {expected}");
|
||||
}
|
||||
|
||||
/// #2755: a cut cylinder with `targets` cuts only those bodies, and so do its pattern and mirror
|
||||
/// copies: a body over the plate that the cylinder was told to leave alone keeps its volume.
|
||||
#[test]
|
||||
fn copies_of_a_primitive_cut_only_its_targets() {
|
||||
let body_volume = |s: &mut Session, name: &str| {
|
||||
let m = run(s, "inspect.measure", json!({"bodies": [name]}));
|
||||
m["bodies"][0]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
};
|
||||
// The command, and the holes in the plate after it (the cylinder's and its copies).
|
||||
for (copy, holes) in [
|
||||
(json!(["solid.pattern.rectangular", {"features": ["Trou"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 30}]), 3.0),
|
||||
(json!(["solid.mirror", {"features": ["Trou"], "plane": {"origin": [50, 0, 0], "normal": [1, 0, 0]}}]), 2.0),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "body_name": "Plaque"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 10, "corner": [0, 0, 5], "operation": "new", "body_name": "Voisin"}));
|
||||
let cyl = json!({"radius": 3, "height": 30, "base": [10, 10, -5], "operation": "cut", "targets": ["Plaque"], "name": "Trou"});
|
||||
run(&mut s, "solid.cylinder", cyl);
|
||||
assert!(rel(body_volume(&mut s, "Voisin"), 60000.0) < 1e-9);
|
||||
let (id, p) = (copy[0].as_str().unwrap_or_default(), copy[1].clone());
|
||||
let r = run(&mut s, id, p);
|
||||
assert_eq!(r["changed_bodies"], json!(["Plaque"]), "{id}");
|
||||
assert!(rel(body_volume(&mut s, "Voisin"), 60000.0) < 1e-9, "{id} cut Voisin");
|
||||
let removed = 60000.0 - body_volume(&mut s, "Plaque");
|
||||
assert!(rel(removed, holes * PI * 9.0 * 10.0) < 1e-3, "{id}: {removed} mm³ cut from Plaque");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,16 +58,10 @@ pub fn vec2_list(cmd: &str, p: &Value, k: &str, max: usize) -> Result<Vec<Vec2>>
|
||||
a.iter().map(|v| vec2(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y] points")))).collect()
|
||||
}
|
||||
|
||||
/// A finite number given for an expression, as the expression's text (`8` → `"8"`, which reads
|
||||
/// as mm or degrees by context).
|
||||
pub fn number_text(n: &serde_json::Number) -> Option<String> {
|
||||
n.as_f64().and_then(finite).map(|v| format!("{v}"))
|
||||
}
|
||||
|
||||
/// An expression parameter: a number (mm or degrees by context) or an expression string.
|
||||
pub fn expr(p: &Value, k: &str) -> Option<Expr> {
|
||||
match p.get(k)? {
|
||||
Value::Number(n) => number_text(n).map(Expr::new),
|
||||
Value::Number(n) => n.as_f64().and_then(finite).map(|v| Expr::new(format!("{v}"))),
|
||||
Value::String(s) if !s.trim().is_empty() && s.len() <= 4096 => Some(Expr::new(s.trim())),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -325,7 +325,6 @@ mod primitive_points;
|
||||
mod rim_cut;
|
||||
mod robustness;
|
||||
mod robustness_goldens;
|
||||
mod sphere_lens;
|
||||
mod spiral_join;
|
||||
mod step_import_options;
|
||||
mod xz_plane;
|
||||
@@ -2691,34 +2690,6 @@ fn unequal_chamfers() {
|
||||
assert!(s.execute("solid.chamfer", &json!({"edges": [[0, 15, 20]], "distance": 1, "distance2": 2, "angle": "30 deg"})).is_err());
|
||||
}
|
||||
|
||||
/// Unequal chamfers of a cylinder's circular rims: each cross-section is a right triangle
|
||||
/// revolved about the axis (Pappus: area × the path of its centroid).
|
||||
#[test]
|
||||
fn unequal_chamfers_on_circular_rims() {
|
||||
use std::f64::consts::PI;
|
||||
let ring = |r: [f64; 3], legs: (f64, f64)| 2.0 * PI * (r[0] + r[1] + r[2]) / 3.0 * 0.5 * legs.0 * legs.1;
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20}));
|
||||
// The measured volume is of the meshed cylinder: compare what each chamfer takes away.
|
||||
let full = volume(&mut s);
|
||||
assert!(rel(full, PI * 100.0 * 20.0) < 1e-3);
|
||||
// Top rim: 2 across the top (the face facing up), 3 down the wall.
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2, "distance2": 3}));
|
||||
let top = ring([10.0, 8.0, 10.0], (2.0, 3.0));
|
||||
assert!(rel(full - volume(&mut s), top) < 5e-3, "{} vs {top}", full - volume(&mut s));
|
||||
// Bottom rim: 2 along the bottom at 30° from it (flipped: the wall would be first).
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 0]], "distance": 2, "angle": "30 deg", "flip": true}));
|
||||
let d2 = 2.0 * 30f64.to_radians().tan();
|
||||
let bottom = ring([10.0, 8.0, 10.0], (2.0, d2));
|
||||
assert!(rel(full - volume(&mut s), top + bottom) < 5e-3, "{} vs {}", full - volume(&mut s), top + bottom);
|
||||
// Flipped on the top rim instead: 2 down the wall, 3 across the top (a different solid).
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[10, 0, 20]], "distance": 2, "distance2": 3, "flip": true}));
|
||||
let flipped = ring([10.0, 7.0, 10.0], (2.0, 3.0));
|
||||
assert!(rel(full - volume(&mut s), flipped) < 5e-3, "{} vs {flipped}", full - volume(&mut s));
|
||||
}
|
||||
|
||||
/// An enclosure built in the usual order: shell, bosses, then the lip on the rim.
|
||||
#[test]
|
||||
fn lip_after_boss() {
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
//! #2750: a negative extrude of the overlap between a circle and a dome outline.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn negative_lens_extrude_cuts_a_partial_sphere_and_reopens() {
|
||||
let mut s = Session::default();
|
||||
let plane = json!({"origin":[0,0,0],"x_dir":[1,0,0],"y_dir":[0,0,1]});
|
||||
run(&mut s, "sketch.create", json!({"name":"DomeProfile","plane":plane}));
|
||||
run(&mut s, "sketch.arc.three_point", json!({"start":[25,0],"through":[0,25],"end":[-25,0]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[-25,0],[25,0]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.revolve", json!({"sketch":"DomeProfile","axis":"x","angle":"90 deg","body_name":"Dome"}));
|
||||
// The same outline a face-attached sketch projects, and the intersecting circle.
|
||||
run(&mut s, "sketch.create", json!({"name":"CutProfile","plane":{"origin":[0,0,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]}}));
|
||||
run(&mut s, "sketch.arc.three_point", json!({"start":[25,0],"through":[0,25],"end":[-25,0]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[-25,0],[25,0]]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,20],"radius":15}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"CutProfile","profiles":[{"point":[0,15]}],"direction":"positive","distance":"-30 mm","operation":"cut"}),
|
||||
);
|
||||
let check = |s: &mut Session| {
|
||||
assert!((volume(s) - 8810.08973246).abs() < 2.);
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1);
|
||||
let body = &state.bodies[0].body;
|
||||
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||
assert!(solidcraft_kernel::measure(body).unwrap().bbox.max.z < 20.01, "no lid across the opening");
|
||||
assert!(body.contains(Vec3::new(0., -10., 2.)));
|
||||
assert!(!body.contains(Vec3::new(0., -10., 15.)));
|
||||
};
|
||||
check(&mut s);
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check(&mut reopened);
|
||||
}
|
||||
@@ -197,7 +197,7 @@ fn moved_component_bodies_are_not_placed_again_when_something_else_changes() {
|
||||
let again = after.body(&rim.name).unwrap();
|
||||
assert!(Arc::ptr_eq(&mesh, &again.mesh()), "an unchanged placed body keeps its mesh");
|
||||
assert!(again.body.same(&rim.body));
|
||||
// The placement moves: new world coordinates, reusing the definition's triangulation.
|
||||
// The placement moves: placed and meshed again where it now is.
|
||||
run(&mut session, "occurrence.move", json!({"occurrence":occurrence,"translate":[0,50,0]}));
|
||||
let moved = session.world_state();
|
||||
let moved_rim = moved.bodies.iter().find(|b| b.source_name.as_deref() == Some("Rim")).unwrap();
|
||||
@@ -213,52 +213,3 @@ fn moved_component_bodies_are_not_placed_again_when_something_else_changes() {
|
||||
assert!(source.mesh().bounds().min.dist(Vec3::ZERO) < 1e-6);
|
||||
assert!(mesh.bounds().min.dist(Vec3::new(100., 0., 0.)) < 1e-6);
|
||||
}
|
||||
|
||||
/// Driving an unchanged definition must map its warm reference triangles, not re-tessellate
|
||||
/// the placed B-rep on the first mesh read of every new pose (viewport bounds does that read).
|
||||
#[test]
|
||||
fn joint_drive_reuses_reference_tessellation_across_rigid_poses() {
|
||||
let mut session = Session::default();
|
||||
run(&mut session, "solid.cylinder", json!({"radius":3,"height":8,"base":[4,0,0],"body_name":"Crank"}));
|
||||
run(&mut session, "component.create", json!({"name":"Crank","parent":"root","activate":false}));
|
||||
run(&mut session, "component.move_bodies", json!({"bodies":["Crank"],"component":"Crank"}));
|
||||
run(
|
||||
&mut session,
|
||||
"joint.as_built",
|
||||
json!({"type":"revolute","name":"Crank","a":0,"b":"Crank","at":{"occurrence":0,"point":[0,0,0],"z":[0,0,1]}}),
|
||||
);
|
||||
let definitions = session.model.state();
|
||||
let original = session.world_state();
|
||||
let local = original.body("Crank").unwrap().mesh();
|
||||
let bounds = local.bounds();
|
||||
for angle in [37., 91., 37., 0.] {
|
||||
let driven = run(&mut session, "joint.drive", json!({"joint":"Crank","value":angle}));
|
||||
assert_eq!(driven["moved"], true);
|
||||
assert!(Arc::ptr_eq(&definitions, &session.model.state()));
|
||||
let world = session.world_state();
|
||||
let body = world.body("Crank").unwrap();
|
||||
let (mesh, work) = crate::kernel::profile::capture(|| body.mesh());
|
||||
assert_eq!(work.tessellate_ms, 0., "a warm definition must not enter the kernel mesher at a new pose: {work:?}");
|
||||
assert_eq!(mesh.positions.len(), local.positions.len());
|
||||
assert_eq!(mesh.normals.len(), local.normals.len());
|
||||
assert_eq!(mesh.edges.iter().map(Vec::len).collect::<Vec<_>>(), local.edges.iter().map(Vec::len).collect::<Vec<_>>());
|
||||
assert_eq!(mesh.triangles, local.triangles);
|
||||
assert_eq!(mesh.tri_face, local.tri_face);
|
||||
assert_eq!(mesh.edge_faces, local.edge_faces);
|
||||
assert_eq!(mesh.seams, local.seams);
|
||||
assert_eq!(mesh.smooth_edges, local.smooth_edges);
|
||||
assert_eq!(mesh.sharp_edges, local.sharp_edges);
|
||||
for (p, q) in local.positions.iter().zip(&mesh.positions) {
|
||||
assert!(crate::doc::apply_point(&body.source_transform, *p).dist(*q) < 1e-8);
|
||||
}
|
||||
for (n, q) in local.normals.iter().zip(&mesh.normals) {
|
||||
assert!(crate::doc::apply_vector(&body.source_transform, *n).dist(*q) < 1e-8);
|
||||
}
|
||||
for (edge, placed) in local.edges.iter().zip(&mesh.edges) {
|
||||
for (p, q) in edge.iter().zip(placed) {
|
||||
assert!(crate::doc::apply_point(&body.source_transform, *p).dist(*q) < 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(local.bounds(), bounds, "old snapshots stay immutable");
|
||||
}
|
||||
|
||||
@@ -2295,11 +2295,9 @@ pub enum ChamferSide {
|
||||
Angle(f64),
|
||||
}
|
||||
|
||||
/// A chamfer with different setbacks on the two faces of each edge: `d1` along the first face
|
||||
/// (the one facing up most, the other with `flip`), the second side by distance or angle.
|
||||
/// Straight edges between planar faces are cut by prisms; curved edges where a planar face meets
|
||||
/// a wall (a circular rim between a plane and a cylinder or cone, convex or concave, and loops of
|
||||
/// lines and arcs) are chamfered along that face's loop.
|
||||
/// A chamfer with different setbacks on the two faces of each (straight) edge: `d1` along the
|
||||
/// first face (the one facing up most, the other with `flip`), the second side by distance or
|
||||
/// angle.
|
||||
pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, flip: bool) -> Result<Body> {
|
||||
body.require_brep("chamfer")?;
|
||||
let ok = |x: f64| x.is_finite() && x > 1e-6 && x < 1e6;
|
||||
@@ -2316,85 +2314,15 @@ pub fn chamfer_sides(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, fl
|
||||
}
|
||||
let size = crate::blend_fit::Size::Sides(d1, side, flip);
|
||||
let counts = record::counts(&[body]);
|
||||
let cut = || -> Result<Body> {
|
||||
let mut cur = crate::boolean_record::operand(body, 0, &counts);
|
||||
for (k, p) in edges.iter().enumerate() {
|
||||
let t = chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k)?;
|
||||
let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
|
||||
cur = record::after(step, &cur, &counts);
|
||||
}
|
||||
Ok(cur)
|
||||
};
|
||||
// A curved edge picked: the prisms cannot cut it, and cutting the others first moves what
|
||||
// its pick lands on.
|
||||
let picks_curved = {
|
||||
let solid = body.deep_copy();
|
||||
let ids = edge_ids(body, &solid, edges)?;
|
||||
guard("chamfer", || Ok(Body::unique_edges(&solid).iter().any(|x| ids.contains(&x.id()) && curved_case(&solid, x, false))))?
|
||||
};
|
||||
let cur = match picks_curved {
|
||||
true => chamfer_sides_curved(body, edges, d1, side, flip, None)?,
|
||||
false => match cut() {
|
||||
Ok(cur) => cur,
|
||||
Err(e) => chamfer_sides_curved(body, edges, d1, side, flip, Some(e))?,
|
||||
},
|
||||
};
|
||||
let mut cur = crate::boolean_record::operand(body, 0, &counts);
|
||||
for (k, p) in edges.iter().enumerate() {
|
||||
let t = chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k)?;
|
||||
let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
|
||||
cur = record::after(step, &cur, &counts);
|
||||
}
|
||||
crate::blend_fit::accept(body, cur, edges, size)
|
||||
}
|
||||
|
||||
/// [`chamfer_sides`] of a selection with curved edges, or one the prisms could not cut (`e`:
|
||||
/// why) with edges running on smoothly into curved ones: whole loops of planar faces and runs of
|
||||
/// one are chamfered along the loop ([`crate::loopblend::loop_chamfer_sides`]), other edges still
|
||||
/// by a prism. A selection of straight edges only keeps the prisms' refusal.
|
||||
fn chamfer_sides_curved(body: &Body, edges: &[Vec3], d1: f64, side: ChamferSide, flip: bool, e: Option<KernelError>) -> Result<Body> {
|
||||
let counts = record::counts(&[body]);
|
||||
let mut cur = crate::boolean_record::healed(body)?;
|
||||
let curved = {
|
||||
let solid = cur.deep_copy();
|
||||
let ids = edge_ids(&cur, &solid, edges)?;
|
||||
guard("chamfer", || {
|
||||
let all = Body::unique_edges(&solid);
|
||||
let curved = |x: &mt::Edge| curved_case(&solid, x, false);
|
||||
Ok(all
|
||||
.iter()
|
||||
.filter(|x| ids.contains(&x.id()))
|
||||
.any(|x| curved(x) || smooth_chain(&solid, x).is_some_and(|chain| all.iter().any(|y| chain.contains(&y.id()) && curved(y)))))
|
||||
})?
|
||||
};
|
||||
if !curved {
|
||||
return Err(e.unwrap_or_else(|| unsupported("these edges")));
|
||||
}
|
||||
// The loop chamfer of the edges nearest `pts`, all on one planar face's loop.
|
||||
let along_loop = |cur: &Body, pts: &[Vec3]| -> Result<Body> {
|
||||
let size = cur.size();
|
||||
let solid = cur.deep_copy();
|
||||
let ids = edge_ids(cur, &solid, pts)?;
|
||||
match crate::loopblend::loop_chamfer_sides(&solid, &ids, size, d1, side, flip) {
|
||||
Some(res) => {
|
||||
let (made, rec) = guard("chamfer", || res)?;
|
||||
Ok(record::after(record::rebuilt(made, &[&solid], &rec)?, cur, &counts))
|
||||
}
|
||||
None => Err(unsupported(
|
||||
"a two-distance or distance-angle chamfer of a curved edge that is not on the boundary of a planar face (an equal-distance chamfer may work there)",
|
||||
)),
|
||||
}
|
||||
};
|
||||
let (groups, rest) = loop_split(&cur, edges)?.unwrap_or_else(|| (Vec::new(), edges.to_vec()));
|
||||
for g in &groups {
|
||||
cur = along_loop(&cur, g)?;
|
||||
}
|
||||
for (k, p) in rest.iter().enumerate() {
|
||||
cur = match chamfer_tool_sides(&cur, *p, d1, Some((side, flip)), k) {
|
||||
Ok(t) => {
|
||||
let step = cut_one(&cur, &t)?.ok_or_else(|| KernelError::Failed("the chamfer removed everything".into()))?;
|
||||
record::after(step, &cur, &counts)
|
||||
}
|
||||
Err(_) => along_loop(&cur, std::slice::from_ref(p))?,
|
||||
};
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// The prism that cuts a chamfer: the body, which of its faces is the chamfer plane, and the
|
||||
/// edge (of the body it chamfers, in its edge order) that face is made in place of.
|
||||
pub(crate) struct Cutter {
|
||||
|
||||
@@ -791,22 +791,7 @@ fn crease(a: &Sel, b: &Sel, q: Vec3, end: Vec3, size: f64) -> Result<mt::Curve>
|
||||
pts.push(p3(p));
|
||||
}
|
||||
pts.push(p3(end));
|
||||
let mut curve = crate::splitface::fit(&pts, size)?;
|
||||
// The last chord tilts away from the cylinders' intersection tangent. At an equal-radius
|
||||
// mitre that tangent continues the sharp third edge; losing it breaks the smooth chain
|
||||
// used to map picks when a later fillet rebuilds these rounds (#2422).
|
||||
let radial = |c: Vec3, d: Vec3| (end - c) - d * (end - c).dot(d);
|
||||
if (a.r - b.r).abs() <= size * 1e-9
|
||||
&& let Some(tangent) = radial(ca, da).cross(radial(cb, db)).normalized()
|
||||
&& let mt::Curve::BSplineCurve(ref mut spline) = curve
|
||||
&& let Some(index) = spline.control_points().len().checked_sub(2)
|
||||
&& let Some(control) = spline.control_points_mut().nth(index)
|
||||
{
|
||||
let toward_end = end - crate::body::from_p3(*control);
|
||||
let tangent = if tangent.dot(toward_end) < 0.0 { -tangent } else { tangent };
|
||||
*control = p3(end - tangent * toward_end.len());
|
||||
}
|
||||
Ok(curve)
|
||||
crate::splitface::fit(&pts, size)
|
||||
}
|
||||
|
||||
/// Keep selected sharp edges while consuming a selected mitre that is on the same smooth
|
||||
@@ -1391,32 +1376,6 @@ mod tests {
|
||||
assert!(fillet_radii(&rounded, &[(seam, 2.0)]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue2422_vertical_after_separate_bottom_rounds() {
|
||||
let origin = Vec3::new(-5.0, 7.2, -2.0);
|
||||
let b = box_solid(origin, origin + Vec3::new(40.0, 80.0, 27.0)).unwrap();
|
||||
let bottom = [Vec3::new(15.0, 7.2, -2.0), Vec3::new(35.0, 47.2, -2.0), Vec3::new(15.0, 87.2, -2.0), Vec3::new(-5.0, 47.2, -2.0)];
|
||||
// The issue has no design attached. Reconstruct its outside corner and bottom mitre,
|
||||
// using the reported picks, with and without the open cavity visible in the screenshot.
|
||||
let picks = [Vec3::new(-5.0, 87.2, 12.5), Vec3::new(-4.283446394180081, 86.48344639418008, -1.5001085324947512)];
|
||||
for base in [b.clone(), crate::shell(&b, &[Vec3::new(15.0, 47.2, 25.0)], 1.0).unwrap()] {
|
||||
let mut rounded = base;
|
||||
for edge in bottom {
|
||||
rounded = fillet(&rounded, &[edge], 2.0).unwrap();
|
||||
}
|
||||
let reference = fillet(&rounded, &picks[..1], 2.0).unwrap();
|
||||
for selection in [picks, [picks[1], picks[0]]] {
|
||||
let result = fillet(&rounded, &selection, 2.0).unwrap();
|
||||
assert_eq!(result.face_count(), reference.face_count());
|
||||
assert_eq!(result.unmeshed_faces(), 0);
|
||||
assert!(result.validity().is_empty(), "{:?}", result.validity());
|
||||
assert!((vol(&result) - vol(&reference)).abs() < 0.05);
|
||||
assert!(!result.contains(Vec3::new(-4.9, 87.1, 10.0)));
|
||||
assert!(result.contains(Vec3::new(-4.1, 86.3, 10.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue1557_all_vertical_corners_match_analytic_volume() {
|
||||
let origin = Vec3::new(10.0, -5.0, 7.0);
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
//! Two-distance and distance-angle chamfers of curved edges: a circular rim between a planar
|
||||
//! face and a cylinder or cone, convex (a cylinder's top) and concave (a boss's foot), and a
|
||||
//! hole's rim. Each chamfer's cross-section is a triangle revolved about the axis, so its volume
|
||||
//! is the triangle's area times the path of its centroid (Pappus).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use crate::build::{box_solid, extrude, extrude_tapered};
|
||||
use crate::{Body, BoolOp, ChamferSide, KernelError, boolean, chamfer, chamfer_sides};
|
||||
|
||||
const TOL: f64 = 1e-3;
|
||||
|
||||
fn volume(b: &Body) -> f64 {
|
||||
b.volume_at(TOL).unwrap()
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// A cylinder of radius 10 and height 20 standing on XY, its seam at `seam`.
|
||||
fn cylinder(seam: f64) -> Body {
|
||||
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, seam), holes: vec![] };
|
||||
extrude(&Plane::XY, &[region], 0.0, 20.0).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
/// The volume of the ring swept by the triangle `(r, z)` corners about the Z axis.
|
||||
fn ring(t: [(f64, f64); 3]) -> f64 {
|
||||
let [(r0, z0), (r1, z1), (r2, z2)] = t;
|
||||
let area = 0.5 * ((r1 - r0) * (z2 - z0) - (r2 - r0) * (z1 - z0)).abs();
|
||||
2.0 * PI * (r0 + r1 + r2) / 3.0 * area
|
||||
}
|
||||
|
||||
fn sound(b: &Body) {
|
||||
assert!(b.validity().is_empty(), "{:?}", b.validity());
|
||||
assert_eq!(b.lumps().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
const RIM: Vec3 = Vec3 { x: 10.0, y: 0.0, z: 20.0 };
|
||||
|
||||
#[test]
|
||||
fn cylinder_rim_by_two_distances() {
|
||||
for seam in [0.0, crate::build::SEAM_ANGLE] {
|
||||
let c = cylinder(seam);
|
||||
let base = volume(&c);
|
||||
// 2 across the top (the face facing up), 3 down the wall.
|
||||
let out = chamfer_sides(&c, &[RIM], 2.0, ChamferSide::Distance(3.0), false).unwrap();
|
||||
sound(&out);
|
||||
let removed = ring([(10.0, 20.0), (8.0, 20.0), (10.0, 17.0)]);
|
||||
assert!((removed - 56.0 * PI).abs() < 1e-9);
|
||||
assert!(rel(volume(&out), base - removed) < 1e-5, "seam {seam}: {} vs {}", volume(&out), base - removed);
|
||||
// Cone faces in place of the rim, as the equal chamfer's.
|
||||
assert_eq!(out.face_count(), chamfer(&c, &[RIM], 2.0).unwrap().face_count());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_rim_by_distance_and_angle() {
|
||||
let c = cylinder(0.0);
|
||||
let base = volume(&c);
|
||||
// 2 across the top at 30° from it: down the wall 2·tan 30° (the faces are square).
|
||||
let out = chamfer_sides(&c, &[RIM], 2.0, ChamferSide::Angle(30f64.to_radians()), false).unwrap();
|
||||
sound(&out);
|
||||
let d2 = 2.0 * 30f64.to_radians().tan();
|
||||
let expected = base - ring([(10.0, 20.0), (8.0, 20.0), (10.0, 20.0 - d2)]);
|
||||
assert!(rel(volume(&out), expected) < 1e-5, "{} vs {expected}", volume(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_rim_flipped() {
|
||||
let c = cylinder(0.0);
|
||||
let base = volume(&c);
|
||||
// Flipped: the wall is the first face, 2 down it and 3 across the top.
|
||||
let out = chamfer_sides(&c, &[RIM], 2.0, ChamferSide::Distance(3.0), true).unwrap();
|
||||
sound(&out);
|
||||
let expected = base - ring([(10.0, 20.0), (7.0, 20.0), (10.0, 18.0)]);
|
||||
assert!((base - expected - 54.0 * PI).abs() < 1e-9);
|
||||
assert!(rel(volume(&out), expected) < 1e-5, "{} vs {expected}", volume(&out));
|
||||
// At 60° from the wall: across the top 2·tan 60°.
|
||||
let out = chamfer_sides(&c, &[RIM], 2.0, ChamferSide::Angle(60f64.to_radians()), true).unwrap();
|
||||
let d2 = 2.0 * 60f64.to_radians().tan();
|
||||
let expected = base - ring([(10.0, 20.0), (10.0 - d2, 20.0), (10.0, 18.0)]);
|
||||
assert!(rel(volume(&out), expected) < 1e-5, "{} vs {expected}", volume(&out));
|
||||
}
|
||||
|
||||
/// The bottom rim, as a box's bottom edge: the bottom faces down, so the wall is the first face.
|
||||
#[test]
|
||||
fn cylinder_bottom_rim_takes_the_wall_first() {
|
||||
let c = cylinder(0.0);
|
||||
let base = volume(&c);
|
||||
let out = chamfer_sides(&c, &[Vec3::new(10.0, 0.0, 0.0)], 2.0, ChamferSide::Distance(3.0), false).unwrap();
|
||||
sound(&out);
|
||||
let expected = base - ring([(10.0, 0.0), (7.0, 0.0), (10.0, 2.0)]);
|
||||
assert!(rel(volume(&out), expected) < 1e-5, "{} vs {expected}", volume(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_distances_are_unchanged() {
|
||||
let c = cylinder(0.0);
|
||||
let base = volume(&c);
|
||||
let equal = chamfer(&c, &[RIM], 2.0).unwrap();
|
||||
let expected = base - ring([(10.0, 20.0), (8.0, 20.0), (10.0, 18.0)]);
|
||||
assert!(rel(volume(&equal), expected) < 1e-5, "{} vs {expected}", volume(&equal));
|
||||
// Two equal distances, and 45° on square faces, are the same chamfer.
|
||||
for side in [ChamferSide::Distance(2.0), ChamferSide::Angle(PI / 4.0)] {
|
||||
let out = chamfer_sides(&c, &[RIM], 2.0, side, false).unwrap();
|
||||
assert!(rel(volume(&out), volume(&equal)) < 1e-7, "{side:?}: {} vs {}", volume(&out), volume(&equal));
|
||||
assert_eq!(out.face_count(), equal.face_count());
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 × 40 × 10 plate with a hole of radius 5 through it at (20, 20).
|
||||
fn holed_plate() -> Body {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 10.0)).unwrap();
|
||||
let hole = crate::build::cylinder(Vec3::new(20.0, 20.0, -5.0), Vec3::Z, 5.0, 20.0).unwrap();
|
||||
boolean(&plate, &hole, BoolOp::Cut).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hole_rim() {
|
||||
let p = holed_plate();
|
||||
let base = volume(&p);
|
||||
let rim = Vec3::new(25.0, 20.0, 10.0);
|
||||
// 2 across the top, away from the hole, and 3 down its wall: a countersink.
|
||||
let out = chamfer_sides(&p, &[rim], 2.0, ChamferSide::Distance(3.0), false).unwrap();
|
||||
sound(&out);
|
||||
let removed = ring([(5.0, 10.0), (7.0, 10.0), (5.0, 7.0)]);
|
||||
assert!((removed - 34.0 * PI).abs() < 1e-9);
|
||||
assert!(rel(volume(&out), base - removed) < 1e-5, "{} vs {}", volume(&out), base - removed);
|
||||
// Flipped, at 30° from the hole's wall.
|
||||
let out = chamfer_sides(&p, &[rim], 2.0, ChamferSide::Angle(30f64.to_radians()), true).unwrap();
|
||||
sound(&out);
|
||||
let d2 = 2.0 * 30f64.to_radians().tan();
|
||||
let expected = base - ring([(5.0, 10.0), (5.0 + d2, 10.0), (5.0, 8.0)]);
|
||||
assert!(rel(volume(&out), expected) < 1e-5, "{} vs {expected}", volume(&out));
|
||||
}
|
||||
|
||||
/// A concave rim: the foot of a boss of radius 5 standing 10 high on a 40 × 40 × 5 plate.
|
||||
#[test]
|
||||
fn boss_foot_fills() {
|
||||
let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 5.0)).unwrap();
|
||||
let boss = crate::build::cylinder(Vec3::new(20.0, 20.0, 5.0), Vec3::Z, 5.0, 10.0).unwrap();
|
||||
let b = boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
|
||||
let base = volume(&b);
|
||||
let foot = Vec3::new(25.0, 20.0, 5.0);
|
||||
// The equal chamfer fills a ring.
|
||||
let equal = chamfer(&b, &[foot], 2.0).unwrap();
|
||||
let filled = ring([(5.0, 5.0), (7.0, 5.0), (5.0, 7.0)]);
|
||||
assert!(rel(volume(&equal), base + filled) < 1e-5, "{} vs {}", volume(&equal), base + filled);
|
||||
// 2 across the plate (facing up), 3 up the boss.
|
||||
let out = chamfer_sides(&b, &[foot], 2.0, ChamferSide::Distance(3.0), false).unwrap();
|
||||
sound(&out);
|
||||
let filled = ring([(5.0, 5.0), (7.0, 5.0), (5.0, 8.0)]);
|
||||
assert!(rel(volume(&out), base + filled) < 1e-5, "{} vs {}", volume(&out), base + filled);
|
||||
// 2 up the boss at 30° from it.
|
||||
let out = chamfer_sides(&b, &[foot], 2.0, ChamferSide::Angle(30f64.to_radians()), true).unwrap();
|
||||
sound(&out);
|
||||
let d2 = 2.0 * 30f64.to_radians().tan();
|
||||
let filled = ring([(5.0, 5.0), (5.0 + d2, 5.0), (5.0, 7.0)]);
|
||||
assert!(rel(volume(&out), base + filled) < 1e-5, "{} vs {}", volume(&out), base + filled);
|
||||
}
|
||||
|
||||
/// A frustum's top rim, between the top and a cone: the faces meet at 90° + the taper.
|
||||
#[test]
|
||||
fn cone_rim() {
|
||||
let taper = 10f64.to_radians();
|
||||
let region = Region2 { outer: Loop2::circle_from(Vec2::ZERO, 10.0, 0.0), holes: vec![] };
|
||||
let f = extrude_tapered(&Plane::XY, ®ion, 20.0, 1.0, -taper).unwrap();
|
||||
let base = volume(&f);
|
||||
let top = 10.0 - 20.0 * taper.tan();
|
||||
let rim = Vec3::new(top, 0.0, 20.0);
|
||||
// Down the cone's slant, away from the top: (sin, −cos) of the taper.
|
||||
let down = |d: f64| (top + d * taper.sin(), 20.0 - d * taper.cos());
|
||||
// The cone is meshed less closely than the cylinder: compare what the chamfer takes away
|
||||
// (within the mesh's error, about 1e-3 of it at this tolerance).
|
||||
let out = chamfer_sides(&f, &[rim], 2.0, ChamferSide::Distance(3.0), false).unwrap();
|
||||
sound(&out);
|
||||
let removed = ring([(top, 20.0), (top - 2.0, 20.0), down(3.0)]);
|
||||
assert!(rel(base - volume(&out), removed) < 3e-3, "{} vs {removed}", base - volume(&out));
|
||||
// By an angle: the triangle edge–A–B has φ = 90° + taper at the edge and 40° at A.
|
||||
let a = 40f64.to_radians();
|
||||
let phi = PI / 2.0 + taper;
|
||||
let d2 = 2.0 * a.sin() / (phi + a).sin();
|
||||
let out = chamfer_sides(&f, &[rim], 2.0, ChamferSide::Angle(a), false).unwrap();
|
||||
sound(&out);
|
||||
let removed = ring([(top, 20.0), (top - 2.0, 20.0), down(d2)]);
|
||||
assert!(rel(base - volume(&out), removed) < 3e-3, "{} vs {removed}", base - volume(&out));
|
||||
// The equal chamfer, as before.
|
||||
let out = chamfer(&f, &[rim], 2.0).unwrap();
|
||||
let removed = ring([(top, 20.0), (top - 2.0, 20.0), down(2.0)]);
|
||||
assert!(rel(base - volume(&out), removed) < 3e-3, "{} vs {removed}", base - volume(&out));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_large_or_unsupported_is_refused() {
|
||||
let c = cylinder(0.0);
|
||||
// Across the whole top: the cone would end in a point.
|
||||
assert!(chamfer_sides(&c, &[RIM], 10.0, ChamferSide::Distance(3.0), false).is_err());
|
||||
assert!(chamfer_sides(&c, &[RIM], 12.0, ChamferSide::Distance(3.0), false).is_err());
|
||||
// Down past the bottom.
|
||||
assert!(chamfer_sides(&c, &[RIM], 2.0, ChamferSide::Distance(25.0), false).is_err());
|
||||
// An angle that runs past the wall.
|
||||
assert!(chamfer_sides(&c, &[RIM], 2.0, ChamferSide::Angle(PI / 2.0 + 0.01), false).is_err());
|
||||
// A cylinder's seam is smooth: nothing to chamfer.
|
||||
assert!(chamfer_sides(&c, &[Vec3::new(10.0, 0.0, 10.0)], 2.0, ChamferSide::Distance(3.0), false).is_err());
|
||||
// A tilted cylinder cut flat: its rim on the cut is an ellipse, which the loop chamfer does
|
||||
// not take. Refused as not supported, never a wrong solid.
|
||||
let tilted = crate::build::cylinder(Vec3::new(0.0, 0.0, -10.0), Vec3::new(1.0, 0.0, 2.0), 5.0, 40.0).unwrap();
|
||||
let cut = box_solid(Vec3::new(-50.0, -50.0, 10.0), Vec3::new(50.0, 50.0, 50.0)).unwrap();
|
||||
let t = boolean(&tilted, &cut, BoolOp::Cut).unwrap().unwrap();
|
||||
let solid = t.deep_copy();
|
||||
let rim = crate::body::Body::unique_edges(&solid).iter().filter_map(crate::blend::edge_mid).find(|m| (m.z - 10.0).abs() < 1e-6).unwrap();
|
||||
match chamfer_sides(&t, &[rim], 1.0, ChamferSide::Distance(2.0), false) {
|
||||
Err(KernelError::Failed(m)) => assert!(m.starts_with("not supported yet"), "{m}"),
|
||||
Err(e) => panic!("{e}"),
|
||||
Ok(_) => panic!("an unequal chamfer of an elliptical rim was built"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The top loop of a plate with rounded corners: lines and quarter circles, planar and
|
||||
/// cylindrical walls, chamfered all round (every edge of the loop picked).
|
||||
#[test]
|
||||
fn rounded_plate_top_loop() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 10.0)).unwrap();
|
||||
let corners = [Vec3::new(0.0, 0.0, 5.0), Vec3::new(40.0, 0.0, 5.0), Vec3::new(40.0, 30.0, 5.0), Vec3::new(0.0, 30.0, 5.0)];
|
||||
let b = crate::fillet(&b, &corners, 5.0).unwrap();
|
||||
let base = volume(&b);
|
||||
let k = 5.0 * (1.0 - std::f64::consts::FRAC_1_SQRT_2);
|
||||
let picks = [
|
||||
Vec3::new(20.0, 0.0, 10.0),
|
||||
Vec3::new(40.0, 15.0, 10.0),
|
||||
Vec3::new(20.0, 30.0, 10.0),
|
||||
Vec3::new(0.0, 15.0, 10.0),
|
||||
Vec3::new(k, k, 10.0),
|
||||
Vec3::new(40.0 - k, k, 10.0),
|
||||
Vec3::new(40.0 - k, 30.0 - k, 10.0),
|
||||
Vec3::new(k, 30.0 - k, 10.0),
|
||||
];
|
||||
// Every pick is taken on the plate as given: cutting the lines first used to leave the arcs'
|
||||
// picks nearest to the new chamfer faces' edges, which were cut instead (corner wedges).
|
||||
let out = chamfer_sides(&b, &picks, 2.0, ChamferSide::Distance(3.0), false).unwrap();
|
||||
sound(&out);
|
||||
// 100 of straight sides with the triangle (2 × 3) and four quarter rings about the corners.
|
||||
let removed = 100.0 * 3.0 + ring([(5.0, 10.0), (3.0, 10.0), (5.0, 7.0)]);
|
||||
assert!(rel(base - volume(&out), removed) < 2e-3, "{} vs {removed}", base - volume(&out));
|
||||
// Flipped: 2 down the walls, 3 across the top.
|
||||
let out = chamfer_sides(&b, &picks, 2.0, ChamferSide::Distance(3.0), true).unwrap();
|
||||
sound(&out);
|
||||
let removed = 100.0 * 3.0 + ring([(5.0, 10.0), (2.0, 10.0), (5.0, 8.0)]);
|
||||
assert!(rel(base - volume(&out), removed) < 2e-3, "{} vs {removed}", base - volume(&out));
|
||||
// Equal distances: as the equal chamfer.
|
||||
let equal = chamfer(&b, &picks, 2.0).unwrap();
|
||||
let out = chamfer_sides(&b, &picks, 2.0, ChamferSide::Distance(2.0), false).unwrap();
|
||||
assert!(rel(volume(&out), volume(&equal)) < 1e-9);
|
||||
}
|
||||
@@ -28,8 +28,6 @@ mod boundary;
|
||||
mod boundary_network;
|
||||
mod bridge;
|
||||
mod build;
|
||||
#[cfg(test)]
|
||||
mod chamfer_sides_curved_tests;
|
||||
mod clearance;
|
||||
mod coaxial_overlap;
|
||||
mod continuity;
|
||||
@@ -115,7 +113,6 @@ mod record;
|
||||
mod seam_faces;
|
||||
mod sew;
|
||||
mod smooth_edges;
|
||||
mod sphere_lens;
|
||||
mod spline_conv;
|
||||
mod splitface;
|
||||
mod step;
|
||||
@@ -179,7 +176,7 @@ pub use iges_out::iges_export_bodies;
|
||||
pub use lip::raise_band;
|
||||
pub use loft_blend::loft_blend;
|
||||
pub use matchsurf::{match_surface, seam_continuity};
|
||||
pub use measure::{BodyMeasure, MassMeasure, measure, measure_mass};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
|
||||
|
||||
+27
-151
@@ -5,15 +5,13 @@
|
||||
//! gets a blend face: a cylinder (or plane, for a chamfer) along a line, a torus (or cone) along
|
||||
//! an arc, all meeting along the profile curves at the joints. Sharp joints at perpendicular
|
||||
//! curved walls follow the intersections of the exact offsets, fitted within tolerance.
|
||||
//! A chamfer may also set back by different distances on the face and up the walls (two
|
||||
//! distances, or a distance and an angle: [`loop_chamfer_sides`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::blend::{ChamferSide, too_large};
|
||||
use crate::blend::too_large;
|
||||
use crate::body::{Solid, from_p3, p3, v3};
|
||||
use crate::record::Rebuild;
|
||||
use crate::{KernelError, Result};
|
||||
@@ -100,15 +98,14 @@ fn offset_joint(prev: &Seg, cur: &Seg, n: Vec3, d: f64) -> Option<Vec3> {
|
||||
|
||||
/// A curved-wall joint is generally not planar. Fit its exact offset slices rather than
|
||||
/// projecting a circular section onto a tangent mitre and revolving that distorted section.
|
||||
/// `(r, rw)`: the round's radius (`rw` unused), or a chamfer's setbacks on the face and up the wall.
|
||||
fn offset_profile(prev: &Seg, cur: &Seg, n: Vec3, side: f64, (r, rw): (f64, f64), round: bool, tol: f64) -> Result<mt::Curve> {
|
||||
fn offset_profile(prev: &Seg, cur: &Seg, n: Vec3, side: f64, r: f64, round: bool, tol: f64) -> Result<mt::Curve> {
|
||||
use mt::ParametricCurve;
|
||||
let point = |u: f64| {
|
||||
let (d, h) = if round {
|
||||
let (sin, cos) = (u * std::f64::consts::FRAC_PI_2).sin_cos();
|
||||
(r * (1.0 - sin), r * (1.0 - cos))
|
||||
} else {
|
||||
(r * (1.0 - u), rw * u)
|
||||
(r * (1.0 - u), r * u)
|
||||
};
|
||||
offset_joint(prev, cur, n, d).map(|p| p + n * (side * h))
|
||||
};
|
||||
@@ -234,40 +231,13 @@ fn face_normal(f: &mt::Face, p: Vec3) -> Option<Vec3> {
|
||||
/// perpendicular walls, with what it built from `solid` (each blend face in place of its loop
|
||||
/// edge). `None` when the edges are not such a loop.
|
||||
pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
|
||||
loop_blend_sized(solid, edge_ids, size, Sizing::Equal { r, round })
|
||||
}
|
||||
|
||||
/// [`loop_blend`] as a chamfer of `d1` on its first face and the second side by `side`. The
|
||||
/// first face is the one facing up most (the planar face or its wall, decided as for a straight
|
||||
/// edge, see [`crate::chamfer_sides`]), the other one with `flip`.
|
||||
pub(crate) fn loop_chamfer_sides(
|
||||
solid: &Solid,
|
||||
edge_ids: &[mt::EdgeID],
|
||||
size: f64,
|
||||
d1: f64,
|
||||
side: ChamferSide,
|
||||
flip: bool,
|
||||
) -> Option<Result<(Solid, Rebuild)>> {
|
||||
loop_blend_sized(solid, edge_ids, size, Sizing::Sides { d1, side, flip })
|
||||
}
|
||||
|
||||
/// How large a loop blend is.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Sizing {
|
||||
/// A round of radius `r` (`round`), or an equal-distance chamfer of `r`.
|
||||
Equal { r: f64, round: bool },
|
||||
/// A chamfer of `d1` on its first face, the second side by `side` ([`loop_chamfer_sides`]).
|
||||
Sides { d1: f64, side: ChamferSide, flip: bool },
|
||||
}
|
||||
|
||||
fn loop_blend_sized(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, sizing: Sizing) -> Option<Result<(Solid, Rebuild)>> {
|
||||
match crate::guard("loop blend", || Ok(loop_blend_inner(solid, edge_ids, size, sizing))) {
|
||||
match crate::guard("loop blend", || Ok(loop_blend_inner(solid, edge_ids, size, r, round))) {
|
||||
Ok(result) => result,
|
||||
Err(error) => Some(Err(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, sizing: Sizing) -> Option<Result<(Solid, Rebuild)>> {
|
||||
fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f64, round: bool) -> Option<Result<(Solid, Rebuild)>> {
|
||||
#[cfg(test)]
|
||||
assert!(crate::guarded(), "loop selection must run inside the kernel guard");
|
||||
let tol = (size * 1e-6).max(1e-7);
|
||||
@@ -281,7 +251,7 @@ fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, sizing: S
|
||||
continue;
|
||||
}
|
||||
if ids.len() == edge_ids.len() {
|
||||
return Some(build(solid, si, &faces, fi, wi, n, size, tol, sizing, None));
|
||||
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, None));
|
||||
}
|
||||
// A run of neighbouring edges of the loop (its ends meet unblended edges).
|
||||
let m = ids.len();
|
||||
@@ -290,7 +260,7 @@ fn loop_blend_inner(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, sizing: S
|
||||
if let [s0] = starts[..]
|
||||
&& (0..edge_ids.len()).all(|j| inside(s0 + j))
|
||||
{
|
||||
return Some(build(solid, si, &faces, fi, wi, n, size, tol, sizing, Some((s0, edge_ids.len()))));
|
||||
return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, Some((s0, edge_ids.len()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,17 +278,12 @@ fn build(
|
||||
n: Vec3,
|
||||
size: f64,
|
||||
tol: f64,
|
||||
sizing: Sizing,
|
||||
r: f64,
|
||||
round: bool,
|
||||
run: Option<(usize, usize)>,
|
||||
) -> Result<(Solid, Rebuild)> {
|
||||
#[cfg(test)]
|
||||
assert!(crate::guarded(), "loop construction must run inside the kernel guard");
|
||||
// `r`: the round's radius or the equal chamfer's size; an unequal chamfer's first distance
|
||||
// (its setbacks come from `frame`).
|
||||
let (r, round, sides) = match sizing {
|
||||
Sizing::Equal { r, round } => (r, round, None),
|
||||
Sizing::Sides { d1, side, flip } => (d1, false, Some((side, flip))),
|
||||
};
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?;
|
||||
let all_edges: Vec<mt::Edge> = wire.edge_iter().cloned().collect();
|
||||
@@ -419,83 +384,16 @@ fn build(
|
||||
let t = s.tangent(p)?.cross(gn).normalized()?;
|
||||
Some(if t.dot(n * side) < 0.0 { -t } else { t })
|
||||
};
|
||||
// An unequal chamfer: whether its first side is the planar face (else the walls). As for a
|
||||
// straight edge, that is the one facing up most (a curved wall by its mean normal along each
|
||||
// edge), the other with `flip`; it must be the same all round the loop.
|
||||
let face_first = match sides {
|
||||
None => true,
|
||||
Some((_, flip)) => {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let up = Vec3::new(1e-6, 1e-3, 1.0);
|
||||
let face_up = n.dot(up);
|
||||
let mut first: Option<bool> = None;
|
||||
for (pi, oi) in owner.iter().enumerate() {
|
||||
if pi > 0 && owner.get(pi - 1) == Some(oi) {
|
||||
continue;
|
||||
}
|
||||
let (Some(e), Some(g)) = (edges.get(*oi), walls.get(pi).and_then(|w| faces.get(*w))) else {
|
||||
return Err(KernelError::Failed("loop edge".into()));
|
||||
};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let ups: Vec<f64> =
|
||||
(0..=8).filter_map(|j| face_normal(g, from_p3(c.subs(t0 + (t1 - t0) * f64::from(j) / 8.0))).map(|gn| gn.dot(up))).collect();
|
||||
if ups.is_empty() {
|
||||
return Err(unsupported("wall normal"));
|
||||
}
|
||||
let wall_up = ups.iter().sum::<f64>() / ups.len() as f64;
|
||||
let mine = (face_up >= wall_up) != flip;
|
||||
if first.is_some_and(|x| x != mine) {
|
||||
return Err(unsupported(
|
||||
"an unequal chamfer round a loop whose first face changes along it (the face faces up more than some walls and less than others)",
|
||||
));
|
||||
}
|
||||
first = Some(mine);
|
||||
}
|
||||
first.unwrap_or(true)
|
||||
}
|
||||
};
|
||||
// Into the face (m), up the wall (t), the setbacks along each and the distance from the edge
|
||||
// to a round's centre.
|
||||
let frame = |i: usize, p: Vec3| -> Option<(Vec3, Vec3, f64, f64, f64)> {
|
||||
let frame = |i: usize, p: Vec3| -> Option<(Vec3, Vec3, f64, f64)> {
|
||||
let s = segs.get(i)?;
|
||||
let (m, t) = (into(s, p), up(i, p)?);
|
||||
let phi = m.dot(t).clamp(-1.0, 1.0).acos();
|
||||
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) {
|
||||
return None;
|
||||
}
|
||||
let (sf, sw) = match sides {
|
||||
None => {
|
||||
let sb = if round { r / (phi / 2.0).tan() } else { r };
|
||||
(sb, sb)
|
||||
}
|
||||
Some((cs, _)) => {
|
||||
let d2 = match cs {
|
||||
ChamferSide::Distance(d) => d,
|
||||
// The triangle edge–A–B: angle φ at the edge, `a` at A (on the first side).
|
||||
ChamferSide::Angle(a) => {
|
||||
let den = (phi + a).sin();
|
||||
if den <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
r * a.sin() / den
|
||||
}
|
||||
};
|
||||
if face_first { (r, d2) } else { (d2, r) }
|
||||
}
|
||||
};
|
||||
Some((m, t, sf, sw, r / (phi / 2.0).sin()))
|
||||
let sb = if round { r / (phi / 2.0).tan() } else { r };
|
||||
Some((m, t, sb, r / (phi / 2.0).sin()))
|
||||
};
|
||||
if let Some((ChamferSide::Angle(a), _)) = sides {
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let p = s.start();
|
||||
if let Some(t) = up(i, p)
|
||||
&& (into(s, p).dot(t).clamp(-1.0, 1.0).acos() + a).sin() <= 1e-9
|
||||
{
|
||||
return Err(KernelError::Invalid("that chamfer angle runs past the other face".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The straight seam between the walls at a sharp joint (direction away from the face).
|
||||
let seam_dir = |i: usize, p: Vec3| -> Option<Vec3> {
|
||||
let o = owner.get(i)?;
|
||||
@@ -528,17 +426,17 @@ fn build(
|
||||
let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) };
|
||||
for i in 0..nj {
|
||||
let p = joint_point(i).unwrap_or_default();
|
||||
let (m, t, sb, sbw, dc) = frame(i.min(k - 1), p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||||
let (m, t, sb, dc) = frame(i.min(k - 1), p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||||
centres.push(p + (m + t).normalized().unwrap_or(m) * dc);
|
||||
if !mitre.get(i).copied().unwrap_or(false) {
|
||||
pa.push(p + m * sb);
|
||||
pb.push(p + t * sbw);
|
||||
pb.push(p + t * sb);
|
||||
continue;
|
||||
}
|
||||
// Sharp joint: the face rails meet where the two offset lines cross; the wall rails
|
||||
// meet on the seam.
|
||||
let ip = (i + k - 1) % k;
|
||||
let (m0, t0, sb0, sbw0, _) = frame(ip, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||||
let (m0, t0, sb0, _) = frame(ip, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||||
let (Some(d0), Some(d1)) = (segs.get(ip).and_then(|x| x.tangent(p)), segs.get(i).and_then(|x| x.tangent(p))) else {
|
||||
return Err(unsupported("degenerate loop edge"));
|
||||
};
|
||||
@@ -549,9 +447,7 @@ fn build(
|
||||
&& t0.dot(n * side) > 1.0 - 1e-9;
|
||||
if exact {
|
||||
let (Some(prev), Some(cur)) = (segs.get(ip), segs.get(i)) else { return Err(unsupported("corner segments")) };
|
||||
pa.push(
|
||||
offset_joint(prev, cur, n, if sides.is_some() { sb } else { r }).ok_or_else(|| too_large("the blend's offset walls do not meet"))?,
|
||||
);
|
||||
pa.push(offset_joint(prev, cur, n, r).ok_or_else(|| too_large("the blend's offset walls do not meet"))?);
|
||||
if let Some(slot) = curved_joint.get_mut(i) {
|
||||
*slot = true;
|
||||
}
|
||||
@@ -560,10 +456,10 @@ fn build(
|
||||
}
|
||||
let e = seam_dir(i, p).ok_or_else(|| unsupported("a sharp corner without one straight seam"))?;
|
||||
let (h0, h1) = (e.dot(t0), e.dot(t));
|
||||
if h0 < 1e-6 || h1 < 1e-6 || (sbw0 / h0 - sbw / h1).abs() > tol * 10.0 {
|
||||
if h0 < 1e-6 || h1 < 1e-6 || (sb0 / h0 - sb / h1).abs() > tol * 10.0 {
|
||||
return Err(unsupported("a sharp corner whose walls meet the blend differently"));
|
||||
}
|
||||
pb.push(p + e * (sbw / h1));
|
||||
pb.push(p + e * (sb / h1));
|
||||
}
|
||||
// Sharp joints between straight rim edges can extend beyond the old corner.
|
||||
// Equal wall slopes reach this path instead of the corner builder, and must
|
||||
@@ -584,14 +480,6 @@ fn build(
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
if let Seg::Arc { a, c, .. } = *s {
|
||||
let toward = into(s, a).dot(c - a) > 0.0;
|
||||
// An unequal chamfer's offset on the face; it never ends in a point (a cone's apex).
|
||||
if sides.is_some() {
|
||||
let reach = frame(i, a).map(|x| x.2).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?;
|
||||
if toward && a.dist(c) <= reach + tol * 10.0 {
|
||||
return Err(too_large("the chamfer is larger than a corner radius"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if toward && (a.dist(c) - r).abs() <= tol * 10.0 && !mitre.get(i).copied().unwrap_or(true) && !mitre.get(nxt(i)).copied().unwrap_or(true)
|
||||
{
|
||||
if let Some(x) = collapsed.get_mut(i) {
|
||||
@@ -660,11 +548,7 @@ fn build(
|
||||
let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) };
|
||||
if curved_joint.get(i).copied().unwrap_or(false) {
|
||||
let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { return Err(unsupported("corner segments")) };
|
||||
let setbacks = match sides {
|
||||
None => (r, r),
|
||||
Some(_) => joint_point(i).and_then(|p| frame(i.min(k - 1), p)).map(|x| (x.2, x.3)).ok_or_else(|| unsupported("frame"))?,
|
||||
};
|
||||
let curve = offset_profile(prev, cur, n, side, setbacks, round, (setbacks.0.min(setbacks.1) * 1e-7).max(1e-9))?;
|
||||
let curve = offset_profile(prev, cur, n, side, r, round, (r * 1e-7).max(1e-9))?;
|
||||
profiles.push(mt::Edge::try_new(a, b, curve).map_err(|e| KernelError::Failed(format!("curved corner: {e}")))?);
|
||||
continue;
|
||||
}
|
||||
@@ -682,7 +566,7 @@ fn build(
|
||||
let d1 = segs.get(i).and_then(|x| x.tangent(p)).ok_or_else(|| unsupported("tangent"))?;
|
||||
let (pa_i, pb_i) = (from_p3(a.point()), from_p3(b.point()));
|
||||
let lam = (pa_i - p).dot(d1);
|
||||
let (_, t, sb, _, _) = frame(i, p).ok_or_else(|| unsupported("frame"))?;
|
||||
let (_, t, sb, _) = frame(i, p).ok_or_else(|| unsupported("frame"))?;
|
||||
let cc = centre(i) + d1 * lam;
|
||||
let q = p + t * sb + d1 * lam;
|
||||
let mid = cc + ((pa_i - cc) + (q - cc)).normalized().unwrap_or(t) * r;
|
||||
@@ -720,11 +604,11 @@ fn build(
|
||||
let piece_of = |s: &Seg| segs.iter().position(|x| std::ptr::eq(x, s)).unwrap_or(0);
|
||||
let face_off = |p: Vec3, s: &Seg| {
|
||||
let i = piece_of(s);
|
||||
frame(i, p).map(|(m, _, sb, _, _)| p + m * sb).unwrap_or(p)
|
||||
frame(i, p).map(|(m, _, sb, _)| p + m * sb).unwrap_or(p)
|
||||
};
|
||||
let wall_off = |p: Vec3, s: &Seg| {
|
||||
let i = piece_of(s);
|
||||
frame(i, p).map(|(_, t, _, sbw, _)| p + t * sbw).unwrap_or(p)
|
||||
frame(i, p).map(|(_, t, sb, _)| p + t * sb).unwrap_or(p)
|
||||
};
|
||||
let face_rails: Vec<Option<mt::Edge>> = (0..k).map(|i| rail(&va, i, &face_off)).collect();
|
||||
if face_rails.iter().zip(&collapsed).any(|(r, c)| r.is_none() && !c) {
|
||||
@@ -889,11 +773,11 @@ fn build(
|
||||
// The cross-section at the start, swept along the whole edge and beyond (the
|
||||
// face may reach past the edge's ends at a mitre).
|
||||
let d = (b - a).normalized().ok_or_else(|| unsupported("degenerate loop edge"))?;
|
||||
let (_, t, sb, sbw, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||||
let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||||
let m = into(s, a);
|
||||
let ext = size;
|
||||
let back = d * -ext;
|
||||
let (fa, wa) = (builder::vertex(p3(a + m * sb + back)), builder::vertex(p3(a + t * sbw + back)));
|
||||
let (fa, wa) = (builder::vertex(p3(a + m * sb + back)), builder::vertex(p3(a + t * sb + back)));
|
||||
let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i) + back)) } else { builder::line(&fa, &wa) };
|
||||
vec![builder::tsweep(&base, v3(b - a + d * (2.0 * ext)))].into()
|
||||
}
|
||||
@@ -917,24 +801,16 @@ fn build(
|
||||
Seg::Arc { a, c, axis, angle, .. } => {
|
||||
// Sweep a radial section, not the shared corner curve: the latter can be
|
||||
// nonplanar and would change the round's radius all along this edge.
|
||||
let (_, t, sb, sbw, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||||
let (fa, wa) = (builder::vertex(p3(a + into(s, a) * sb)), builder::vertex(p3(a + t * sbw)));
|
||||
let (_, t, sb, _) = frame(i, a).ok_or_else(|| unsupported("frame"))?;
|
||||
let (fa, wa) = (builder::vertex(p3(a + into(s, a) * sb)), builder::vertex(p3(a + t * sb)));
|
||||
let base = if round { builder::circle_arc(&fa, &wa, p3(profile_mid(i))) } else { builder::line(&fa, &wa) };
|
||||
builder::rsweep(&base, p3(c), v3(axis), mt::Rad(angle))
|
||||
}
|
||||
};
|
||||
let mut surf = swept.face_iter().next().map(|x| x.oriented_surface()).ok_or_else(|| KernelError::Failed("blend surface".into()))?;
|
||||
// Outward: toward the profile centre on a concave loop, away from it on a convex one.
|
||||
// An unequal chamfer's profile has no centre: its middle, away from the edge on a concave
|
||||
// loop (where the chamfer fills) and toward it on a convex one.
|
||||
let (c, mid) = (centre(i), profile_mid(i));
|
||||
let (mid, outward) = match (sides, joint_point(i)) {
|
||||
(Some(_), Some(p)) => {
|
||||
let m = (from_p3(pr.front().point()) + from_p3(pr.back().point())) * 0.5;
|
||||
(m, (m - p) * side)
|
||||
}
|
||||
_ => (mid, (c - mid) * side),
|
||||
};
|
||||
let outward = (c - mid) * side;
|
||||
if let Some((u, v)) = surf.search_nearest_parameter(p3(mid), None, 100) {
|
||||
let nn = surf.normal(u, v);
|
||||
if Vec3::new(nn.x, nn.y, nn.z).dot(outward) < 0.0 {
|
||||
|
||||
@@ -55,16 +55,6 @@ fn min_edge_radius(m: &Mesh) -> Option<f64> {
|
||||
best
|
||||
}
|
||||
|
||||
/// Volume, area, centre and box of a body, as [`measure`] works them out, without the topology
|
||||
/// counts (and the tessellation they take).
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct MassMeasure {
|
||||
pub volume: f64,
|
||||
pub area: f64,
|
||||
pub centroid: Vec3,
|
||||
pub bbox: Aabb3,
|
||||
}
|
||||
|
||||
/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size, finer for
|
||||
/// small radii down to 5e-6). Faces whose fine tessellation disagrees with a coarse one (a
|
||||
/// meshing failure) use the coarse result.
|
||||
@@ -88,69 +78,14 @@ pub fn measure(b: &Body) -> Result<BodyMeasure> {
|
||||
merged,
|
||||
});
|
||||
}
|
||||
let (m, topo) = mass(b, true)?;
|
||||
let topo = match topo {
|
||||
Some(t) => t,
|
||||
None => b.tessellate((b.size() * 1e-3).max(1e-3))?,
|
||||
};
|
||||
Ok(BodyMeasure {
|
||||
volume: m.volume,
|
||||
area: m.area,
|
||||
centroid: m.centroid,
|
||||
bbox: m.bbox,
|
||||
faces: b.face_count(),
|
||||
edges: b.edge_count(),
|
||||
vertices: b.vertex_count(),
|
||||
shells: b.shell_count(),
|
||||
merged: merged_topology(b, &topo)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// [`measure`]'s volume, area, centre and box alone: the same numbers, a tessellation fewer
|
||||
/// (for callers that need no topology counts, such as an interference check).
|
||||
pub fn measure_mass(b: &Body) -> Result<MassMeasure> {
|
||||
if b.triangle_mesh().is_some() {
|
||||
let m = measure(b)?;
|
||||
return Ok(MassMeasure { volume: m.volume, area: m.area, centroid: m.centroid, bbox: m.bbox });
|
||||
}
|
||||
Ok(mass(b, false)?.0)
|
||||
}
|
||||
|
||||
/// The mass properties of a B-rep body from three tessellations: a coarse one (which sets how
|
||||
/// fine the others go), then a fine and a medium one at once on desktop (one after the other
|
||||
/// in the browser). With `topology`, also the coarse unrefined mesh the topology counts take,
|
||||
/// made alongside them.
|
||||
fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
|
||||
let size = b.size();
|
||||
let coarse_tol = (size * 1e-3).max(1e-3);
|
||||
let coarse = b.tessellate_with(coarse_tol, true)?;
|
||||
let coarse = b.tessellate_with((size * 1e-3).max(1e-3), true)?;
|
||||
// Chord error relative to a radius sets the volume error: keep it near 3e-4 of the
|
||||
// smallest radius (thin tubes, small fillets).
|
||||
let tight = min_edge_radius(&coarse).map(|r| r * 3e-4).unwrap_or(f64::INFINITY);
|
||||
let fine_tol = (size * 5e-5).min(tight).max(size * 5e-6).max(1e-4);
|
||||
let mut jobs = vec![(fine_tol, true), ((fine_tol * 5.0).max(2e-4), true)];
|
||||
if topology {
|
||||
jobs.push((coarse_tol, false));
|
||||
}
|
||||
// Independent tessellations of one body: each on its own thread, the calling thread's
|
||||
// profile counting their wall time once as tessellation. Not from inside a worker pool: a
|
||||
// pool thread blocked on threads of its own could leave their meshing (which runs on the
|
||||
// shared pool) without a worker.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let threads = if rayon::current_thread_index().is_none() { jobs.len() } else { 1 };
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let threads = 1;
|
||||
let made = crate::profile::timed(crate::profile::Kind::Tessellate, || {
|
||||
crate::par::map_on_threads(&jobs, threads, |&(tol, refine)| b.tessellate_with(tol, refine))
|
||||
});
|
||||
let mut made = made.into_iter().map(|m| m.unwrap_or_else(|| Err(crate::KernelError::Internal("tessellation failed".into()))));
|
||||
let missing = || crate::KernelError::Internal("tessellation missing".into());
|
||||
let fine = made.next().ok_or_else(missing)??;
|
||||
let medium = made.next().ok_or_else(missing)??;
|
||||
let topo = match made.next() {
|
||||
Some(m) => Some(m?),
|
||||
None => None,
|
||||
};
|
||||
let fine = b.tessellate_with(fine_tol, true)?;
|
||||
let medium = b.tessellate_with((fine_tol * 5.0).max(2e-4), true)?;
|
||||
let nf = b.face_count();
|
||||
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
|
||||
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
|
||||
@@ -170,31 +105,15 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{BoolOp, boolean, cylinder, torus};
|
||||
|
||||
/// The mass properties alone are [`measure`]'s, to the bit, with the tessellations made at
|
||||
/// once; and on a curved intersection they are the textbook ones.
|
||||
#[test]
|
||||
fn mass_alone_is_the_full_measure() {
|
||||
let ring = torus(Vec3::ZERO, 10.0, 2.0).unwrap();
|
||||
let post = cylinder(Vec3::new(10.0, 0.0, -5.0), Vec3::Z, 1.5, 10.0).unwrap();
|
||||
let shared = boolean(&ring, &post, BoolOp::Intersect).unwrap().unwrap();
|
||||
for b in [&ring, &shared] {
|
||||
let (full, mass) = (measure(b).unwrap(), measure_mass(b).unwrap());
|
||||
assert_eq!(full.volume.to_bits(), mass.volume.to_bits());
|
||||
assert_eq!(full.area.to_bits(), mass.area.to_bits());
|
||||
assert_eq!(full.centroid, mass.centroid);
|
||||
assert_eq!((full.bbox.min, full.bbox.max), (mass.bbox.min, mass.bbox.max));
|
||||
}
|
||||
let want = 2.0 * std::f64::consts::PI.powi(2) * 10.0 * 4.0;
|
||||
let got = measure_mass(&ring).unwrap().volume;
|
||||
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}");
|
||||
assert!(measure(&ring).unwrap().merged.faces > 0);
|
||||
}
|
||||
Ok(BodyMeasure {
|
||||
volume,
|
||||
area,
|
||||
centroid,
|
||||
bbox: coarse.bounds().union(&fine.bounds()),
|
||||
faces: b.face_count(),
|
||||
edges: b.edge_count(),
|
||||
vertices: b.vertex_count(),
|
||||
shells: b.shell_count(),
|
||||
merged: merged_topology(b, &b.tessellate((size * 1e-3).max(1e-3))?)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1196,18 +1196,6 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
|
||||
return Ok(Some(body));
|
||||
}
|
||||
}
|
||||
// A lens cut from a sphere's projected outline carries a redundant cylinder
|
||||
// tangent to the sphere (#2750). Remove it only after proving that it
|
||||
// encloses the entire target, keeping the other wall and both caps exact.
|
||||
if attempt == 1 && op == BoolOp::Cut {
|
||||
let (lens, diagnostics) = capture_boolean_diagnostics(|| crate::sphere_lens::cut(a, b, &boolean_whole));
|
||||
if let Some(Ok(result)) = lens
|
||||
&& result.as_ref().is_none_or(|body| check(body).0)
|
||||
{
|
||||
merge(diagnostics);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
// A whole turn of a cylinder or cone bounded by one wire along its seam (as STEP files
|
||||
// write them) can't be divided by a loop lying inside it, and the tries below end in a
|
||||
// nudged tool, or fail: the same boolean with such faces in two halves, which is exact.
|
||||
|
||||
@@ -59,21 +59,6 @@ pub fn capture<T>(f: impl FnOnce() -> T) -> (T, KernelTimes) {
|
||||
(r, times)
|
||||
}
|
||||
|
||||
/// Count kernel time spent on other threads for this thread's running capture (when one runs
|
||||
/// and no timed call does): work this thread handed out and waited for. The caller keeps the
|
||||
/// sum within the wall time it waited, so the totals still never pass what the command took.
|
||||
pub fn credit(times: KernelTimes) {
|
||||
let ok = |x: f64| if x.is_finite() && x > 0.0 { x } else { 0.0 };
|
||||
STATE.with(|s| {
|
||||
let mut st = s.get();
|
||||
if st.on && !st.busy {
|
||||
st.times.boolean_ms += ok(times.boolean_ms);
|
||||
st.times.tessellate_ms += ok(times.tessellate_ms);
|
||||
s.set(st);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Time `f` as `kind` when a capture is running and no timed call is.
|
||||
pub(crate) fn timed<T>(kind: Kind, f: impl FnOnce() -> T) -> T {
|
||||
let st = STATE.with(Cell::get);
|
||||
@@ -137,19 +122,6 @@ mod tests {
|
||||
assert!(t.boolean_ms + t.tessellate_ms <= elapsed + 1e-6, "{t:?} in {elapsed} ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_credited_from_other_threads_counts_only_inside_a_capture() {
|
||||
let t = KernelTimes { boolean_ms: 4.0, tessellate_ms: 1.0 };
|
||||
credit(t);
|
||||
let ((), got) = capture(|| {
|
||||
credit(t);
|
||||
credit(KernelTimes { boolean_ms: f64::NAN, tessellate_ms: -3.0 });
|
||||
// Inside a timed call it would count twice: it is the call's time already.
|
||||
timed(Kind::Boolean, || credit(t));
|
||||
});
|
||||
assert_eq!((got.boolean_ms >= 4.0, got.boolean_ms < 8.0, got.tessellate_ms), (true, true, 1.0), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inner_captures_add_to_the_outer_one_and_a_panic_restores_it() {
|
||||
let ((), outer) = capture(|| {
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
//! A lens extruded from a sphere's projected outline (#2750). One of its two
|
||||
//! cylinders encloses the whole target and only touches the sphere. Removing that
|
||||
//! redundant constraint gives the same cut without a tangent intersection.
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||||
|
||||
use crate::boolean_record as recorded;
|
||||
use crate::history::Change;
|
||||
use crate::{Body, BoolOp, FaceSource, FaceSurface, Result, face_surface};
|
||||
|
||||
/// Maximum signed height of a line or circular arc, including interior extrema.
|
||||
fn height(segment: Seg2, plane: Plane, cap: Plane) -> Option<f64> {
|
||||
let at = |p| cap.height(plane.to_world(p));
|
||||
let ends = at(segment.start()).max(at(segment.end()));
|
||||
match segment {
|
||||
Seg2::Line { .. } => Some(ends),
|
||||
Seg2::Arc { center, radius, .. } => {
|
||||
let n = cap.normal();
|
||||
let direction = Vec2::new(n.dot(plane.x), n.dot(plane.y));
|
||||
let probe = center + direction.normalized().unwrap_or(Vec2::X) * radius;
|
||||
Some(ends.max(at(segment.point_at(segment.closest_param(probe)))))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize a quarter ball by its complete boundary: one spherical face, two
|
||||
/// perpendicular semicircular caps sharing their diameter, each cap inside the
|
||||
/// other's outward halfspace. The opposite cap orientation (three quarters of a
|
||||
/// ball) is refused. Whole-span curve certificates prove the caps, not samples.
|
||||
fn enclosing_ball(body: &Body, tol: f64) -> Option<(Vec3, f64, Vec<Plane>)> {
|
||||
if body.is_mesh() || body.shell_count() != 1 || body.face_count() != 3 {
|
||||
return None;
|
||||
}
|
||||
let surfaces = (0..3).map(|i| face_surface(body, i)).collect::<Option<Vec<_>>>()?;
|
||||
let (sphere, center, radius) = surfaces.iter().enumerate().find_map(|(i, surface)| match *surface {
|
||||
FaceSurface::Sphere { center, radius } => Some((i, center, radius)),
|
||||
_ => None,
|
||||
})?;
|
||||
let spherical = body.solid.face_iter().nth(sphere)?;
|
||||
if spherical.boundaries().len() != 1 || spherical.edge_iter().count() != 2 {
|
||||
return None;
|
||||
}
|
||||
let sphere_edges: std::collections::HashSet<_> = spherical.edge_iter().map(|e| e.id()).collect();
|
||||
let caps: Vec<_> = surfaces
|
||||
.iter()
|
||||
.filter_map(|surface| match *surface {
|
||||
FaceSurface::Plane { normal, point } => Plane::from_normal(point, normal),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (cap0, cap1) = (*caps.first()?, *caps.get(1)?);
|
||||
if caps.len() != 2 || cap0.normal().dot(cap1.normal()).abs() > 1e-9 || cap0.height(center).abs() > tol || cap1.height(center).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
let mut work = 2_000_000usize;
|
||||
let mut diameters = Vec::new();
|
||||
for (i, face) in body.solid.face_iter().enumerate() {
|
||||
if i == sphere {
|
||||
continue;
|
||||
}
|
||||
let FaceSurface::Plane { normal, point } = *surfaces.get(i)? else { return None };
|
||||
let plane = Plane::from_normal(point, normal)?;
|
||||
if face.boundaries().len() != 1 || face.edge_iter().count() != 2 {
|
||||
return None;
|
||||
}
|
||||
let c = plane.to_local(center);
|
||||
let mut arc = 0.;
|
||||
let mut lines = 0;
|
||||
for edge in face.edge_iter() {
|
||||
if plane.height(crate::body::from_p3(edge.front().point())).abs() > tol
|
||||
|| plane.height(crate::body::from_p3(edge.back().point())).abs() > tol
|
||||
{
|
||||
return None;
|
||||
}
|
||||
for segment in crate::prism_curve::segment(&edge, &plane, tol, &mut work)? {
|
||||
match segment {
|
||||
Seg2::Line { a, b } if (a + b - c * 2.).len() <= tol && (a.dist(c) - radius).abs() <= tol => {
|
||||
diameters.push(edge.id());
|
||||
lines += 1;
|
||||
}
|
||||
Seg2::Arc { center: q, radius: r, sweep, .. } if q.dist(c) + (r - radius).abs() <= tol && sphere_edges.contains(&edge.id()) => {
|
||||
arc += sweep.abs();
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
if height(segment, plane, cap0)? > tol || height(segment, plane, cap1)? > tol {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if lines != 1 || (arc - std::f64::consts::PI).abs() > 1e-8 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if diameters.len() != 2 || diameters.first()? != diameters.get(1)? {
|
||||
return None;
|
||||
}
|
||||
Some((center, radius, caps))
|
||||
}
|
||||
|
||||
/// Replace only a construction-certified, convex two-circle lens. Its cutting cylinder stays exact; a cap moves
|
||||
/// only into a halfspace proved to be outside the target. The added tool material is outside the ball.
|
||||
fn relaxed_tool(a: &Body, b: &Body) -> Option<Body> {
|
||||
if b.is_mesh() || b.shell_count() != 1 || b.face_count() != 4 {
|
||||
return None;
|
||||
}
|
||||
let tol = (a.size().max(b.size()) * 1e-9).max(1e-8);
|
||||
let sources = b.face_sources()?;
|
||||
let (region, plane) = sources.iter().find_map(|source| match source {
|
||||
Some(FaceSource::ExtrudeCap { high: true, region, plane }) => Some((region, *plane)),
|
||||
_ => None,
|
||||
})?;
|
||||
let bottom = sources.iter().find_map(|source| match source {
|
||||
Some(FaceSource::ExtrudeCap { high: false, region: r, plane }) if r == region => Some(*plane),
|
||||
_ => None,
|
||||
})?;
|
||||
if !region.holes.is_empty() || region.outer.segs.len() != 2 || !region.outer.is_closed(tol) {
|
||||
return None;
|
||||
}
|
||||
let circles: Vec<(Vec2, f64)> = region
|
||||
.outer
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, sweep, .. } if sweep > 0.0 && sweep <= std::f64::consts::PI + 1e-9 => Some((center, radius)),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let ((c0, r0), (c1, r1)) = (*circles.first()?, *circles.get(1)?);
|
||||
let distance = c0.dist(c1);
|
||||
if distance <= (r0 - r1).abs() + tol || distance >= r0 + r1 - tol {
|
||||
return None;
|
||||
}
|
||||
let (center, radius, caps) = enclosing_ball(a, tol)?;
|
||||
let projected = plane.to_local(center);
|
||||
let redundant = circles.iter().position(|(c, r)| projected.dist(*c) + radius <= *r + tol)?;
|
||||
let (c, r) = *circles.get(1 - redundant)?;
|
||||
let height = plane.height(bottom.origin);
|
||||
if !height.is_finite() || height >= -tol || plane.normal().dot(bottom.normal()) > -1.0 + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
// The proven quarter ball lies behind each cap. Extend a coincident tool
|
||||
// end outward into that empty halfspace; this avoids the generic nudge's
|
||||
// paper-thin lid across the opening, without moving the cutting cylinder.
|
||||
let on_cap = |end: Plane| caps.iter().any(|cap| cap.normal().dot(end.normal()) > 1.0 - 1e-9 && cap.height(end.origin).abs() <= tol);
|
||||
let (low, high) = (on_cap(bottom), on_cap(plane));
|
||||
if !low && !high {
|
||||
return None;
|
||||
}
|
||||
let margin = (a.size().max(b.size()) * 0.01).max(1e-5);
|
||||
let lo = height - if low { margin } else { 0. };
|
||||
let hi = if high { margin } else { 0. };
|
||||
let mut tool =
|
||||
crate::extrude(&plane, &[Region2 { outer: Loop2::circle_from(c, r, crate::build::SEAM_ANGLE), holes: vec![] }], lo, hi).ok()?.pop()?;
|
||||
// Each rebuilt surface names its original tool face. Extended portions never
|
||||
// meet the target, so every face used in the cut still lies in that input face.
|
||||
let mut faces = Vec::new();
|
||||
for i in 0..tool.face_count() {
|
||||
let surface = face_surface(&tool, i)?;
|
||||
let original = (0..b.face_count()).find(|j| match (surface, face_surface(b, *j)) {
|
||||
(FaceSurface::Plane { normal: n, .. }, Some(FaceSurface::Plane { normal: m, .. })) => n.dot(m) > 1.0 - 1e-9,
|
||||
(FaceSurface::Cylinder { origin: p, axis: n, radius: r }, Some(FaceSurface::Cylinder { origin: q, axis: m, radius: s })) => {
|
||||
n.dot(m).abs() > 1.0 - 1e-9 && (p - q).cross(n).len() + (r - s).abs() <= tol
|
||||
}
|
||||
_ => false,
|
||||
})?;
|
||||
faces.push(crate::record::from_face(0, original, Change::Modified));
|
||||
}
|
||||
let history = crate::record::from_faces(&tool.solid, 1, faces);
|
||||
tool = tool.with_checked_history(Some(history), &crate::record::counts(&[b]));
|
||||
Some(tool)
|
||||
}
|
||||
|
||||
pub(crate) fn cut(a: &Body, b: &Body, run: &dyn Fn(&Body, &Body, BoolOp) -> Result<Option<Body>>) -> Option<Result<Option<Body>>> {
|
||||
let tool = crate::guard("sphere lens tool", || Ok(relaxed_tool(a, b))).ok()??;
|
||||
let result = run(a, &tool, BoolOp::Cut).ok()?;
|
||||
crate::boolean_guard::check_retry(a, b, BoolOp::Cut, result.as_ref()).ok()?;
|
||||
Some(Ok(result.map(|body| {
|
||||
let history = recorded::through(&body, 1, tool.history());
|
||||
body.with_checked_history(history, &crate::record::counts(&[a, b]))
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sphere_lens_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,73 +0,0 @@
|
||||
use super::*;
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
fn dome() -> Body {
|
||||
let profile = Region2 {
|
||||
outer: Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 25., start: 0., sweep: PI },
|
||||
Seg2::Line { a: Vec2::new(-25., 0.), b: Vec2::new(25., 0.) },
|
||||
],
|
||||
},
|
||||
holes: vec![],
|
||||
};
|
||||
let plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
crate::revolve_whole(&plane, &[profile], Vec2::ZERO, Vec2::X, FRAC_PI_2).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
fn lens(enclosing_radius: f64) -> Region2 {
|
||||
crate::planar_regions_boolean(
|
||||
&[Region2 { outer: Loop2::circle(Vec2::ZERO, enclosing_radius), holes: vec![] }],
|
||||
&[Region2 { outer: Loop2::circle(Vec2::new(0., 20.), 15.), holes: vec![] }],
|
||||
BoolOp::Intersect,
|
||||
1e-7,
|
||||
)
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn tool(region: Region2, depth: f64) -> Body {
|
||||
let plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
|
||||
crate::extrude(&plane, &[region], 0., depth).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projected_spherical_outline_does_not_block_a_lens_cut() {
|
||||
let target = dome();
|
||||
let cutter = tool(lens(25.), 30.);
|
||||
let (result, diagnostics) = crate::capture_boolean_diagnostics(|| crate::boolean(&target, &cutter, BoolOp::Cut));
|
||||
let result = result.unwrap().unwrap();
|
||||
assert!(diagnostics.events.iter().all(|event| !event.strategy.alters_result()), "{diagnostics:?}");
|
||||
assert!(result.validity().is_empty(), "{:?}", result.validity());
|
||||
assert_eq!(result.lumps().unwrap().len(), 1);
|
||||
// Integrating the half-disc cross-section left by the offset bore gives
|
||||
// 8810.08973246 mm³ (R=25, bore radius=15, bore centre z=20).
|
||||
let measured = crate::measure(&result).unwrap();
|
||||
assert!((measured.volume - 8810.08973246).abs() < 2., "{measured:?}");
|
||||
assert!(measured.bbox.max.z < 20.01, "no thin lid may remain above the cut: {measured:?}");
|
||||
assert!(result.contains(Vec3::new(0., -10., 2.)));
|
||||
assert!(!result.contains(Vec3::new(0., -10., 15.)));
|
||||
assert_eq!(measured.merged.face_types.get("cylinder"), Some(&1));
|
||||
let history = result.history().expect("the retry composes the tool's face history");
|
||||
history.check(&result, &[&target, &cutter]).unwrap();
|
||||
assert!(history.faces.iter().all(|f| !f.origins.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_an_enclosing_wall_can_be_removed_and_cap_depth_stays_exact() {
|
||||
let target = dome();
|
||||
assert!(relaxed_tool(&target, &tool(lens(24.), 30.)).is_none(), "a cylinder inside the sphere removes real material");
|
||||
let rectangle =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-4., 5.), Vec2::new(4., 5.), Vec2::new(4., 15.), Vec2::new(-4., 15.)]), holes: vec![] };
|
||||
assert!(relaxed_tool(&target, &tool(rectangle, 30.)).is_none());
|
||||
let cutter = tool(lens(25.), 7.);
|
||||
let relaxed = relaxed_tool(&target, &cutter).unwrap();
|
||||
let bounds = crate::measure(&relaxed).unwrap().bbox;
|
||||
assert!((bounds.min.y + 7.).abs() < 1e-6 && bounds.max.y > 0., "only the end outside the quarter ball is extended: {bounds:?}");
|
||||
let cut = crate::boolean(&target, &cutter, BoolOp::Cut).unwrap().unwrap();
|
||||
assert!(cut.contains(Vec3::new(0., -8., 15.)), "material past the pocket floor stays");
|
||||
assert!(!cut.contains(Vec3::new(0., -6., 15.)), "the shallow pocket removes material up to its floor");
|
||||
let box_target = crate::box_solid(Vec3::new(-25., -25., 0.), Vec3::new(25., 0., 25.)).unwrap();
|
||||
assert!(relaxed_tool(&box_target, &cutter).is_none());
|
||||
}
|
||||
@@ -293,33 +293,6 @@ fn parameters_drive_the_design() {
|
||||
assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 50.0), "{d}");
|
||||
}
|
||||
|
||||
/// `set_parameter` takes the new value as `value` or as `expression` (the name parameters.change
|
||||
/// uses), which it used to refuse instead of forwarding.
|
||||
#[test]
|
||||
fn set_parameter_takes_value_or_expression() {
|
||||
let mut s = server();
|
||||
let def = tool_definitions().into_iter().find(|d| d["name"] == "set_parameter").unwrap();
|
||||
assert_eq!(def["inputSchema"]["required"], json!(["name"]), "{def}");
|
||||
assert!(def["inputSchema"]["properties"]["expression"].is_object(), "{def}");
|
||||
tool(&mut s, "set_parameter", json!({"name": "len", "expression": "40 mm"}));
|
||||
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": "len", "width": 10, "height": 10, "corner": [0, 0, 0]}}));
|
||||
assert!(rel(volume(&mut s), 4000.0) < 1e-9);
|
||||
let r = tool(&mut s, "set_parameter", json!({"name": "len", "expression": 50}));
|
||||
assert_eq!(r["value"], 50.0, "{r}");
|
||||
assert!(rel(volume(&mut s), 5000.0) < 1e-9);
|
||||
// Both, when they agree.
|
||||
let r = tool(&mut s, "set_parameter", json!({"name": "len", "value": 60, "expression": "60"}));
|
||||
assert_eq!(r["value"], 60.0, "{r}");
|
||||
// Both disagreeing, or neither, is an error that changes nothing.
|
||||
for args in [json!({"name": "len", "value": 70, "expression": "80"}), json!({"name": "len"}), json!({"name": "len", "value": null})] {
|
||||
let r = raw_tool(&mut s, "set_parameter", args.clone());
|
||||
assert_eq!(r["result"]["isError"], json!(true), "{args}: {r}");
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default();
|
||||
assert!(text.contains("`value`") && text.contains("`expression`"), "{args}: {text}");
|
||||
}
|
||||
assert!(rel(volume(&mut s), 6000.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Design properties (#1015) are set through `execute` and come back in `inspect_design`.
|
||||
#[test]
|
||||
fn design_properties_show_in_inspect_design() {
|
||||
|
||||
+5
-23
@@ -164,19 +164,16 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"set_parameter",
|
||||
"Set a parameter",
|
||||
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
|
||||
(\"40 mm\", \"width * 2\", \"30 deg\"); `expression` is the same thing under the name the parameters.change command \
|
||||
uses. Give one of the two (both only when they agree). Returns the new value, the features recomputed (`recomputed`), the \
|
||||
features whose earlier result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any \
|
||||
features that now fail.",
|
||||
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed (`recomputed`), the features whose earlier \
|
||||
result for the same inputs was reused instead (`restored`, e.g. after setting a value back) and any features that now fail.",
|
||||
obj(
|
||||
json!({
|
||||
"name": string("Parameter name"),
|
||||
"value": {"type": ["number", "string"], "description": "Number or expression (or give it as `expression`)"},
|
||||
"expression": {"type": ["number", "string"], "description": "Same as `value` (the parameters.change name); give one of the two"},
|
||||
"value": {"type": ["number", "string"], "description": "Number or expression"},
|
||||
"unit": string("mm, deg or \"\" (unitless); default from the expression"),
|
||||
"comment": string("Comment shown in the parameters table"),
|
||||
}),
|
||||
&["name"],
|
||||
&["name", "value"],
|
||||
),
|
||||
Effect::Edits,
|
||||
),
|
||||
@@ -422,7 +419,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
}
|
||||
"set_parameter" => {
|
||||
let mut p = pick(a, &["name", "unit", "comment"]);
|
||||
p["expression"] = parameter_value(a)?;
|
||||
p["expression"] = a.get("value").cloned().unwrap_or(Value::Null);
|
||||
exec(b, "parameters.change", p)
|
||||
}
|
||||
"export" => exec(b, "file.export", pick(a, &["path", "format", "bodies", "mesh_prefix", "effort", "velocity"])),
|
||||
@@ -439,21 +436,6 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<V
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_parameter`'s new value: `value` or `expression` (the parameters.change name), one of
|
||||
/// them, or both when they agree (`50` and `"50"` do).
|
||||
fn parameter_value(a: &Map<String, Value>) -> Result<Value, String> {
|
||||
let text = |v: &Value| match v {
|
||||
Value::String(s) => s.trim().to_string(),
|
||||
v => v.to_string(),
|
||||
};
|
||||
match (a.get("value").filter(|v| !v.is_null()), a.get("expression").filter(|v| !v.is_null())) {
|
||||
(Some(v), None) | (None, Some(v)) => Ok(v.clone()),
|
||||
(Some(v), Some(e)) if text(v) == text(e) => Ok(v.clone()),
|
||||
(Some(_), Some(_)) => Err("set_parameter: `value` and `expression` disagree; give one of them".into()),
|
||||
(None, None) => Err("set_parameter: give the new value as `value` (or `expression`): a number (mm) or an expression".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
|
||||
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
|
||||
}
|
||||
|
||||
@@ -284,7 +284,6 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
||||
}
|
||||
"engine.commands" => wrap(app.session.execute("engine.commands", &json!({})).map_err(|e| e.to_string())),
|
||||
"document.inspect" => wrap(app.session.execute("document.inspect", p).map_err(|e| e.to_string())),
|
||||
"ui.messages" => ok(crate::messages::control(app, p)),
|
||||
"ui.inspect" => ok(json!({
|
||||
"ui": serde_json::to_value(&app.ui).unwrap_or_default(),
|
||||
"visibility": app.session.visibility,
|
||||
|
||||
@@ -463,7 +463,7 @@ impl Dialog {
|
||||
"sketch.create" => Dialog::new(Kind::Sketch, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]),
|
||||
"solid.extrude" => Dialog::new(
|
||||
Kind::Extrude {
|
||||
distance: "10 mm".into(),
|
||||
distance: crate::prefs::extrude_start_distance(app),
|
||||
direction: 0,
|
||||
operation: usize::from(has_bodies),
|
||||
auto_op: true,
|
||||
@@ -3403,7 +3403,7 @@ fn fillet_kind(size: &str, chamfer: bool) -> Kind {
|
||||
/// A length field's first text: `number` in the design's units, with the unit written out so the
|
||||
/// field shows it as the other dialogs show theirs ("20 mm", "20 in"). OK makes the size the bare
|
||||
/// number would.
|
||||
pub(crate) fn length_default(s: &Session, number: &str) -> String {
|
||||
fn length_default(s: &Session, number: &str) -> String {
|
||||
match s.doc.with_design_unit(number, ValueKind::Length) {
|
||||
e if e != number => e,
|
||||
// A millimetre design (or one whose units are not a length): bare numbers are mm.
|
||||
@@ -3616,6 +3616,7 @@ pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
return crate::dialogs_save_mesh::run(app, k, &d.inputs);
|
||||
}
|
||||
let commands = apply_commands(app, d)?;
|
||||
let new_extrude = remembered_extrude_distance(app, d);
|
||||
if let Some((_, marker)) = d.editing {
|
||||
// Record the actual pre-edit timeline state in the command's undo snapshot.
|
||||
roll_marker(app, marker)?;
|
||||
@@ -3639,9 +3640,23 @@ pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(distance) = new_extrude {
|
||||
app.preferences.extrude_last_distance = distance;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The distance a new (not edited) extrude was committed with, in mm, for the "Last used"
|
||||
/// starting distance; none when the extent isn't a plain distance.
|
||||
pub(crate) fn remembered_extrude_distance(app: &SolveApp, d: &Dialog) -> Option<String> {
|
||||
let Kind::Extrude { distance, all, .. } = &d.kind else { return None };
|
||||
if d.editing.is_some() || *all || to_object_on(&d.inputs) {
|
||||
return None;
|
||||
}
|
||||
let v = app.session.doc.eval(distance, ValueKind::Length).ok()?;
|
||||
(v.is_finite() && v.abs() > 1e-9).then(|| format!("{v} mm"))
|
||||
}
|
||||
|
||||
/// Placement used to edit a definition in the selected authoring occurrence.
|
||||
pub(crate) fn feature_frame(s: &Session, component: u64) -> solidcraft_engine::doc::Mat {
|
||||
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! The Help menu (the ? button on the application bar): Documentation (the online book), About
|
||||
//! SolidCraft (version, commit, build date, licences, attributions, fonts), the keyboard shortcuts
|
||||
//! (read only), Messages (every status message of the run, #2763), and Report an Issue, which
|
||||
//! opens a local editable report draft.
|
||||
//! (read only), and Report an Issue, which opens a local editable report draft.
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
@@ -44,7 +43,7 @@ pub fn diagnostics(app: &SolveApp) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `messages`, `report`, `crashes`.
|
||||
/// Run a Help menu item: `docs`, `about`, `shortcuts`, `report`, `crashes`.
|
||||
pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
app.help.menu = false;
|
||||
match item {
|
||||
@@ -55,7 +54,6 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
app.keymap.read_only = true;
|
||||
}
|
||||
"crashes" => crate::crash_ui::open(app),
|
||||
"messages" => app.messages.open = true,
|
||||
"report" => {
|
||||
let dialog = app.dialog.clone();
|
||||
let error = dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| app.preview.error.clone());
|
||||
@@ -76,7 +74,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
("docs", "Documentation"),
|
||||
("about", "About SolidCraft"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("messages", "Messages…"),
|
||||
("report", "Report an Issue…"),
|
||||
("crashes", "Crash Reports…"),
|
||||
] {
|
||||
@@ -98,7 +95,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
}
|
||||
about(app, ctx);
|
||||
crate::crash_ui::show(app, ctx);
|
||||
crate::messages::show(app, ctx);
|
||||
}
|
||||
|
||||
fn about(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
|
||||
@@ -75,7 +75,6 @@ mod material_card_review;
|
||||
mod measurement_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_tests;
|
||||
pub mod messages;
|
||||
#[cfg(test)]
|
||||
mod pattern_arrow_tests;
|
||||
#[cfg(test)]
|
||||
@@ -327,8 +326,6 @@ pub struct SolveApp {
|
||||
pub keymap: keymap::Keymap,
|
||||
/// The Help menu and the About window.
|
||||
pub help: help::HelpState,
|
||||
/// Every status message of this run, for the Messages window ([`messages`], #2763).
|
||||
pub messages: messages::Messages,
|
||||
/// An editable local issue draft and optional captured app frame.
|
||||
pub issue_report: issue_report::ReportState,
|
||||
/// The Crash Reports window.
|
||||
@@ -455,7 +452,6 @@ impl SolveApp {
|
||||
sbox: shortcut_box::ShortcutBox::default(),
|
||||
keymap: keymap::Keymap::default(),
|
||||
help: help::HelpState::default(),
|
||||
messages: messages::Messages::default(),
|
||||
issue_report: issue_report::ReportState::default(),
|
||||
crashes: crash_ui::CrashUi::default(),
|
||||
updater: update::Updater::default(),
|
||||
@@ -1019,9 +1015,7 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
pub fn set_status(&mut self, s: impl Into<String>, error: bool) {
|
||||
let (text, at) = (s.into(), now_ms());
|
||||
self.messages.push(&text, error, at);
|
||||
self.status = Some((text, at, error));
|
||||
self.status = Some((s.into(), now_ms(), error));
|
||||
}
|
||||
|
||||
pub fn open_path(&mut self, path: &str) {
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
//! Messages (#2763): every status message of this run, kept so it can be read and copied after
|
||||
//! the status chip has faded. Help › Messages… and a click on the chip open the window; the
|
||||
//! control channel reads them as `ui.messages`.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::i18n::{format, tr};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Messages kept (the oldest go first).
|
||||
pub const KEEP: usize = 200;
|
||||
/// The longest message kept, in characters (a longer one is cut, saying so).
|
||||
const MAX_CHARS: usize = 4000;
|
||||
|
||||
/// One status message.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
pub text: String,
|
||||
pub error: bool,
|
||||
/// When it last came (ms since the Unix epoch).
|
||||
pub at: f64,
|
||||
/// How many times in a row it came.
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Messages {
|
||||
/// Oldest first.
|
||||
list: VecDeque<Message>,
|
||||
/// The window is open.
|
||||
pub open: bool,
|
||||
}
|
||||
|
||||
impl Messages {
|
||||
/// Keep a message: the same one again in a row counts up instead.
|
||||
pub fn push(&mut self, text: &str, error: bool, at: f64) {
|
||||
if let Some(last) = self.list.back_mut()
|
||||
&& last.text == text
|
||||
&& last.error == error
|
||||
{
|
||||
last.count = last.count.saturating_add(1);
|
||||
last.at = at;
|
||||
return;
|
||||
}
|
||||
let text =
|
||||
if text.chars().count() > MAX_CHARS { format!("{}…", text.chars().take(MAX_CHARS).collect::<String>()) } else { text.to_string() };
|
||||
self.list.push_back(Message { text, error, at, count: 1 });
|
||||
while self.list.len() > KEEP {
|
||||
self.list.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Oldest first.
|
||||
pub fn iter(&self) -> impl DoubleEndedIterator<Item = &Message> + ExactSizeIterator {
|
||||
self.list.iter()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.list.clear();
|
||||
}
|
||||
|
||||
/// The messages as text, oldest first, one per line: how long ago, `error:` for errors, the
|
||||
/// text and the repeat count.
|
||||
pub fn as_text(&self, now: f64) -> String {
|
||||
self.list.iter().map(|m| format!("{}\t{}", ago(now, m.at), line(m))).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// A message as one line: `error:` for errors, and `(×n)` when it came more than once.
|
||||
fn line(m: &Message) -> String {
|
||||
let repeats = if m.count > 1 { format!(" (×{})", m.count) } else { String::new() };
|
||||
format!("{}{}{repeats}", if m.error { "error: " } else { "" }, m.text)
|
||||
}
|
||||
|
||||
/// How long ago `at` was, at `now` (both ms): seconds, minutes or hours.
|
||||
fn ago(now: f64, at: f64) -> String {
|
||||
let s = ((now - at) / 1000.0).max(0.0).floor();
|
||||
let (id, n) = if s < 60.0 {
|
||||
("messages.ago_s", s)
|
||||
} else if s < 3600.0 {
|
||||
("messages.ago_min", (s / 60.0).floor())
|
||||
} else {
|
||||
("messages.ago_h", (s / 3600.0).floor())
|
||||
};
|
||||
format(id, &[("n", &format!("{n:.0}"))])
|
||||
}
|
||||
|
||||
/// `ui.messages`: `clear?: true` empties the list, `open?: bool` shows or hides the window; the
|
||||
/// reply lists them, oldest first (`text`, `error`, `at` in ms since the epoch, `count`).
|
||||
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
app.messages.clear();
|
||||
}
|
||||
if let Some(open) = p.get("open").and_then(Value::as_bool) {
|
||||
app.messages.open = open;
|
||||
}
|
||||
let list: Vec<Value> = app.messages.iter().map(|m| json!({"text": m.text, "error": m.error, "at": m.at, "count": m.count})).collect();
|
||||
json!({ "messages": list, "open": app.messages.open })
|
||||
}
|
||||
|
||||
/// The Messages window.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !app.messages.open {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let now = crate::now_ms();
|
||||
let mut open = true;
|
||||
crate::frame::window(ctx, tr("messages.title"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_messages"))
|
||||
.open(&mut open)
|
||||
.default_size(vec2(460.0, 360.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let copy = ui.add_enabled(app.messages.iter().len() > 0, egui::Button::new(tr("messages.copy_all")));
|
||||
crate::scenario::publish_handle("messages:copy", copy.rect.center());
|
||||
if copy.clicked() {
|
||||
ctx.copy_text(app.messages.as_text(now));
|
||||
}
|
||||
let clear = ui.add_enabled(app.messages.iter().len() > 0, egui::Button::new(tr("messages.clear")));
|
||||
crate::scenario::publish_handle("messages:clear", clear.rect.center());
|
||||
if clear.clicked() {
|
||||
app.messages.clear();
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if app.messages.iter().len() == 0 {
|
||||
ui.label(RichText::new(tr("messages.none")).color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
for m in app.messages.iter().rev() {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.add_sized(vec2(70.0, 14.0), egui::Label::new(RichText::new(ago(now, m.at)).small().color(t.text_dim)));
|
||||
let text = RichText::new(line(m)).color(if m.error { t.error } else { t.text });
|
||||
ui.add(egui::Label::new(text).wrap().selectable(true));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
app.messages.open = false;
|
||||
}
|
||||
// The ages tick on while it is open.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(1));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{KEEP, Messages};
|
||||
|
||||
#[test]
|
||||
fn repeats_count_up_and_the_oldest_go_past_the_limit() {
|
||||
let mut m = Messages::default();
|
||||
m.push("Box done", false, 1000.0);
|
||||
m.push("Box done", false, 2000.0);
|
||||
m.push("`face` is required", true, 3000.0);
|
||||
let got: Vec<(&str, bool, u32)> = m.iter().map(|x| (x.text.as_str(), x.error, x.count)).collect();
|
||||
assert_eq!(got, [("Box done", false, 2), ("`face` is required", true, 1)]);
|
||||
assert_eq!(m.as_text(63_000.0), "1 min ago\tBox done (×2)\n1 min ago\terror: `face` is required");
|
||||
for i in 0..KEEP + 5 {
|
||||
m.push(&format!("message {i}"), false, 0.0);
|
||||
}
|
||||
assert_eq!(m.iter().len(), KEEP);
|
||||
assert_eq!(m.iter().next().map(|x| x.text.as_str()), Some("message 5"), "the oldest went first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_very_long_message_is_cut() {
|
||||
let mut m = Messages::default();
|
||||
m.push(&"x".repeat(10_000), true, 0.0);
|
||||
assert_eq!(m.iter().next().map(|x| x.text.chars().count()), Some(4001));
|
||||
}
|
||||
|
||||
/// Status messages land in the list, a click on the chip and Help › Messages… open the window,
|
||||
/// and `ui.messages` reads, opens and clears it.
|
||||
#[test]
|
||||
fn status_messages_are_kept_and_the_window_opens() {
|
||||
use serde_json::json;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.set_status("STEP export needs a solid body", true);
|
||||
h.app.set_status("Box done", false);
|
||||
h.frames(2);
|
||||
let chip = crate::scenario::handle_at("status:chip").expect("the chip is drawn");
|
||||
h.call("ui.click", json!({"x": chip.x, "y": chip.y}));
|
||||
h.frames(2);
|
||||
assert!(h.app.messages.open, "a click on the chip opens the messages");
|
||||
assert!(crate::scenario::handle_at("messages:copy").is_some());
|
||||
let out = h.call("ui.messages", json!({}));
|
||||
let texts: Vec<&str> = out["result"]["messages"].as_array().into_iter().flatten().filter_map(|m| m["text"].as_str()).collect();
|
||||
assert_eq!(texts, ["STEP export needs a solid body", "Box done"], "{out}");
|
||||
assert_eq!(out["result"]["messages"][0]["error"], true);
|
||||
let out = h.call("ui.messages", json!({"clear": true, "open": false}));
|
||||
assert_eq!((out["result"]["messages"].as_array().map(Vec::len), out["result"]["open"].as_bool()), (Some(0), Some(false)));
|
||||
crate::help::run(&mut h.app, &h.ctx.clone(), "messages");
|
||||
assert!(h.app.messages.open, "Help › Messages… opens it too");
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,22 @@ pub enum OrbitAround {
|
||||
Cursor,
|
||||
}
|
||||
|
||||
/// Where a new Extrude dialog starts its distance (Preferences: "New extrude distance").
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExtrudeStart {
|
||||
/// 10 mm, as it always was.
|
||||
#[default]
|
||||
Fixed,
|
||||
/// The distance of the last extrude that was committed.
|
||||
LastUsed,
|
||||
/// The value typed in `extrude_custom_distance`.
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// The distance the Extrude dialog opened at before this was a preference.
|
||||
pub const DEFAULT_EXTRUDE_DISTANCE: &str = "10 mm";
|
||||
|
||||
/// The browser sidebar's first width, and the width [`Prefs::default`] restores; the panel
|
||||
/// itself clamps what the user drags to its own range.
|
||||
pub const DEFAULT_BROWSER_WIDTH: f32 = 250.0;
|
||||
@@ -336,6 +352,12 @@ pub struct Prefs {
|
||||
pub smooth_wheel_zoom: bool,
|
||||
/// Two-finger trackpad scroll pans (off: it zooms like the wheel); pinch always zooms.
|
||||
pub trackpad_pan: bool,
|
||||
/// Where a new Extrude dialog starts its distance (editing an extrude keeps its own).
|
||||
pub extrude_start: ExtrudeStart,
|
||||
/// The distance a new Extrude starts at when `extrude_start` is `custom` (a length expression).
|
||||
pub extrude_custom_distance: String,
|
||||
/// The last distance a new Extrude was committed with, in mm (empty until one is).
|
||||
pub extrude_last_distance: String,
|
||||
/// Horizontal trackpad and thumb-wheel scrolling pans the view the other way (#2007).
|
||||
pub pan_reverse_x: bool,
|
||||
/// View changes (axis view, Look At, Home) animate; `look_at_speed` scales how fast.
|
||||
@@ -459,6 +481,9 @@ impl Default for Prefs {
|
||||
smooth_wheel_zoom: true,
|
||||
trackpad_pan: true,
|
||||
pan_reverse_x: false,
|
||||
extrude_start: ExtrudeStart::Fixed,
|
||||
extrude_custom_distance: DEFAULT_EXTRUDE_DISTANCE.into(),
|
||||
extrude_last_distance: String::new(),
|
||||
look_at_anim: true,
|
||||
look_at_speed: 1.0,
|
||||
up_axis: "z".into(),
|
||||
@@ -757,6 +782,8 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
PAGE_DESIGN => {
|
||||
p.up_axis = d.up_axis;
|
||||
p.offer_stand_up = d.offer_stand_up;
|
||||
p.extrude_start = d.extrude_start;
|
||||
p.extrude_custom_distance = d.extrude_custom_distance;
|
||||
}
|
||||
PAGE_GRAPHICS => {
|
||||
let msaa = p.msaa != d.msaa;
|
||||
@@ -856,6 +883,19 @@ pub fn nav_mode(app: &SolveApp, button: NavButton, mods: Modifiers) -> Option<Na
|
||||
nav_preset(&app.preferences.nav).mode(button, mods)
|
||||
}
|
||||
|
||||
/// The distance a new Extrude dialog opens with. Anything unusable (no extrude yet, a custom
|
||||
/// value that isn't a length) falls back to the fixed 10 mm.
|
||||
pub fn extrude_start_distance(app: &SolveApp) -> String {
|
||||
let p = &app.preferences;
|
||||
let wanted = match p.extrude_start {
|
||||
ExtrudeStart::Fixed => return DEFAULT_EXTRUDE_DISTANCE.into(),
|
||||
ExtrudeStart::LastUsed => p.extrude_last_distance.as_str(),
|
||||
ExtrudeStart::Custom => p.extrude_custom_distance.as_str(),
|
||||
};
|
||||
let ok = app.session.doc.eval(wanted, solidcraft_engine::doc::expr::Kind::Length).is_ok_and(|v| v.is_finite() && v.abs() > 1e-9);
|
||||
if ok { wanted.trim().to_string() } else { DEFAULT_EXTRUDE_DISTANCE.into() }
|
||||
}
|
||||
|
||||
/// +1, or −1 when horizontal scrolling pans the other way (#2007).
|
||||
pub fn pan_sign_x(app: &SolveApp) -> f32 {
|
||||
if app.preferences.pan_reverse_x { -1.0 } else { 1.0 }
|
||||
@@ -1365,6 +1405,18 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, crate::i18n::tr_label("Which axis points up in the views (axis view, Home, Look At). Designs are unchanged."));
|
||||
ui.label(crate::i18n::tr_label("New extrude distance"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.radio_value(&mut app.preferences.extrude_start, ExtrudeStart::Fixed, crate::i18n::tr_label("Fixed (10 mm)"));
|
||||
ui.radio_value(&mut app.preferences.extrude_start, ExtrudeStart::LastUsed, crate::i18n::tr_label("Last used"));
|
||||
ui.radio_value(&mut app.preferences.extrude_start, ExtrudeStart::Custom, crate::i18n::tr_label("Custom"));
|
||||
ui.add_enabled(
|
||||
app.preferences.extrude_start == ExtrudeStart::Custom,
|
||||
egui::TextEdit::singleline(&mut app.preferences.extrude_custom_distance).desired_width(80.0),
|
||||
);
|
||||
});
|
||||
ui.end_row();
|
||||
note(ui, crate::i18n::tr_label("The distance a new Extrude starts with. Editing an existing extrude keeps its own."));
|
||||
ui.label(crate::i18n::tr_label("Offer to stand up Y-up files"));
|
||||
ui.checkbox(&mut app.preferences.offer_stand_up, "")
|
||||
.on_hover_text(crate::i18n::tr_label("After a STEP or IGES file opens with Z up, offer a quarter turn about X"));
|
||||
@@ -2154,6 +2206,50 @@ mod tests {
|
||||
assert_eq!(other.preferences.nav, OPENSCAD_NAV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extrude_start_distance_has_three_modes_and_defaults_to_ten_mm() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
assert_eq!(serde_json::from_value::<Prefs>(json!({})).unwrap().extrude_start, ExtrudeStart::Fixed);
|
||||
let opened = |app: &SolveApp| match crate::dialogs::Dialog::for_command(app, "solid.extrude").unwrap().kind {
|
||||
crate::dialogs::Kind::Extrude { distance, .. } => distance,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(opened(&app), "10 mm", "the default is unchanged");
|
||||
// Fixed ignores whatever was used or typed.
|
||||
set(&mut app, &json!({"extrude_custom_distance": "25 mm", "extrude_last_distance": "7 mm"})).unwrap();
|
||||
assert_eq!(opened(&app), "10 mm");
|
||||
set(&mut app, &json!({"extrude_start": "custom"})).unwrap();
|
||||
assert_eq!(opened(&app), "25 mm");
|
||||
set(&mut app, &json!({"extrude_custom_distance": "nonsense"})).unwrap();
|
||||
assert_eq!(opened(&app), "10 mm", "an unusable custom value falls back");
|
||||
set(&mut app, &json!({"extrude_start": "last_used", "extrude_last_distance": ""})).unwrap();
|
||||
assert_eq!(opened(&app), "10 mm", "nothing used yet");
|
||||
set(&mut app, &json!({"extrude_last_distance": "7 mm"})).unwrap();
|
||||
assert_eq!(opened(&app), "7 mm");
|
||||
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(opened(&other), "7 mm", "persists with the preferences");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_used_remembers_new_extrudes_only() {
|
||||
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
let mut d = crate::dialogs::Dialog::for_command(&app, "solid.extrude").unwrap();
|
||||
if let Some((_, _, text)) = d.primary() {
|
||||
*text = "12.5 mm".into();
|
||||
}
|
||||
assert_eq!(crate::dialogs::remembered_extrude_distance(&app, &d).as_deref(), Some("12.5 mm"));
|
||||
if let Some((_, _, text)) = d.primary() {
|
||||
*text = "bogus".into();
|
||||
}
|
||||
assert_eq!(crate::dialogs::remembered_extrude_distance(&app, &d), None, "not a length");
|
||||
if let Some((_, _, text)) = d.primary() {
|
||||
*text = "3 mm".into();
|
||||
}
|
||||
d.editing = Some((1, None));
|
||||
assert_eq!(crate::dialogs::remembered_extrude_distance(&app, &d), None, "editing an extrude doesn't count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_reverse_x_defaults_off_persists_and_flips_the_sign() {
|
||||
assert!(!serde_json::from_value::<Prefs>(json!({})).unwrap().pan_reverse_x);
|
||||
|
||||
@@ -297,7 +297,7 @@ pub fn open(app: &mut SolveApp, at: Pos2) -> bool {
|
||||
fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
||||
let (rows, total) = rows_capped(app, proj, at);
|
||||
if rows.is_empty() {
|
||||
app.set_status("Nothing to select here", false);
|
||||
app.status = Some(("Nothing to select here".into(), crate::now_ms(), false));
|
||||
return false;
|
||||
}
|
||||
close(app);
|
||||
@@ -488,7 +488,7 @@ pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
||||
let next = rows.iter().position(|r| crate::selection::same_item(&r.sel, &c.chosen)).map_or(0, |k| (k + 1) % rows.len());
|
||||
let Some(row) = rows.get(next).cloned() else { return false };
|
||||
apply(app, &row, false);
|
||||
app.set_status(format!("{} ({} of {})", row.label, next + 1, rows.len()), false);
|
||||
app.status = Some((format!("{} ({} of {})", row.label, next + 1, rows.len()), crate::now_ms(), false));
|
||||
remember(app, c.at, Some(row.sel));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -226,86 +226,6 @@ fn corner_window_takes_the_corner_then_applies_the_typed_size() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2420: the shared corner window shows the design unit, including both chamfer legs.
|
||||
#[test]
|
||||
fn corner_defaults_show_design_units() {
|
||||
for units in ["mm", "cm", "m", "in", "ft"] {
|
||||
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.two_distance", "sketch.chamfer.distance_angle"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("document.units", json!({"units": units})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.step(&json!({"start":command})).unwrap();
|
||||
let Some(crate::dialogs::Kind::SketchModify(crate::sketch_modify_ui::Mod::Corner { size, distance2, angle, .. })) =
|
||||
h.app.dialog.as_ref().map(|d| &d.kind)
|
||||
else {
|
||||
panic!("{command}: no corner window");
|
||||
};
|
||||
assert_eq!(size, &format!("2 {units}"), "{command}");
|
||||
assert_eq!(distance2, &format!("2 {units}"), "{command}");
|
||||
assert_eq!(angle, "45 deg", "length units must not change angles");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A typed inch radius must reach both corner selection routes as a persistent dimension.
|
||||
#[test]
|
||||
fn fillet_window_reads_design_units_and_preserves_explicit_units_and_parameters() {
|
||||
for (units, text, radius) in
|
||||
[("in", "0.25", 6.35), ("cm", "0.25", 2.5), ("mm", "0.25", 0.25), ("in", "2 mm", 2.0), ("mm", "0.25 in", 6.35), ("in", "rounding", 3.0)]
|
||||
{
|
||||
for lines in [false, true] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.length_decimals = 4;
|
||||
h.app.run("document.units", json!({"units":units})).unwrap();
|
||||
h.app.run("parameters.add", json!({"name":"rounding", "expression":"3 mm"})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
|
||||
// Keep the outer size fixed when the driving radius changes.
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":"40 mm"})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l2"],"value":"30 mm"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
||||
if lines {
|
||||
for point in [[25, 0, 0], [40, 15, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
} else {
|
||||
h.step(&json!({"click":{"world":[40,0,0]}})).unwrap();
|
||||
}
|
||||
retype(&mut h, "dialog:Radius", text);
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{units}, {text}, lines {lines}: {:?}", h.app.status);
|
||||
let check = |session: &solidcraft_engine::Session, expected: f64| {
|
||||
let state = session.world_state();
|
||||
let ss = state.sketch(1).unwrap();
|
||||
let arc = ss
|
||||
.sketch
|
||||
.curves
|
||||
.iter()
|
||||
.find_map(|c| match c.kind {
|
||||
CurveKind::Arc { c: center, a: start, .. } => Some(ss.sketch.point(center).unwrap().dist(ss.sketch.point(start).unwrap())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!((arc - expected).abs() < 1e-6, "{units}, {text}, lines {lines}: radius {arc}, expected {expected}");
|
||||
let area = 1200.0 - expected * expected * (1.0 - std::f64::consts::PI / 4.0);
|
||||
assert!(
|
||||
(ss.profiles[0].area - area).abs() < 1e-6,
|
||||
"{units}, {text}, lines {lines}: area {}, expected {area}, radius {expected}",
|
||||
ss.profiles[0].area
|
||||
);
|
||||
};
|
||||
check(&h.app.session, radius);
|
||||
if text == "rounding" {
|
||||
h.app.run("parameters.change", json!({"name":"rounding", "expression":"4 mm"})).unwrap();
|
||||
check(&h.app.session, 4.0);
|
||||
}
|
||||
let reopened = solidcraft_engine::Session::new(solidcraft_engine::doc::Document::from_json(&h.app.session.doc.to_json()).unwrap());
|
||||
check(&reopened, if text == "rounding" { 4.0 } else { radius });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's reason to exist: a chamfer angle other than the 45 degrees the command used to
|
||||
/// receive hard-coded. 10 mm along the first line at 30 degrees puts the second cut at
|
||||
/// 10 * sin 30 / sin 60 mm, where 45 degrees would put it at 10 mm.
|
||||
|
||||
@@ -158,12 +158,12 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
}
|
||||
|
||||
/// The dialog for a sketch modify command.
|
||||
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let corner = |chamfer: bool, ctype: usize| Mod::Corner {
|
||||
chamfer,
|
||||
ctype,
|
||||
size: crate::dialogs::length_default(&app.session, "2"),
|
||||
distance2: crate::dialogs::length_default(&app.session, "2"),
|
||||
size: "2 mm".into(),
|
||||
distance2: "2 mm".into(),
|
||||
angle: "45 deg".into(),
|
||||
flip: false,
|
||||
};
|
||||
@@ -768,14 +768,12 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
Mod::Corner { chamfer: false, size, .. } => {
|
||||
let corners = corner_ids(app, inputs, 0);
|
||||
let lines = line_ids(inputs, 0);
|
||||
// The engine keeps its millimetre API; bare UI values use the design unit.
|
||||
// Keep expressions intact so the radius remains a driving dimension.
|
||||
let radius = app.session.doc.with_design_unit(size.trim(), ValueKind::Length);
|
||||
// The radius is checked where it is evaluated, against the document's parameters.
|
||||
match (corners.is_empty(), lines.as_slice()) {
|
||||
(true, []) => return Err("select the corners to round first".into()),
|
||||
(false, []) => ("sketch.fillet", json!({"points": corners, "radius": radius})),
|
||||
(false, []) => ("sketch.fillet", json!({"points": corners, "radius": size.trim()})),
|
||||
// Two lines name their corner even where other lines end at its point (#1824).
|
||||
(true, [a, b]) => ("sketch.fillet", json!({"a": a, "b": b, "radius": radius})),
|
||||
(true, [a, b]) => ("sketch.fillet", json!({"a": a, "b": b, "radius": size.trim()})),
|
||||
_ => return Err("select corner points, or the two lines of one corner".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +139,7 @@ pub struct DeviceMotion {
|
||||
/// picker is open. Returns whether there was motion to hand over; it then asks for the next frame
|
||||
/// itself.
|
||||
pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
|
||||
// Only the window with focus: with two windows open, the cap moves the one being used, not both.
|
||||
if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || axes == Axes::default() || !ctx.input(|i| i.focused) {
|
||||
if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || axes == Axes::default() {
|
||||
return false;
|
||||
}
|
||||
let speed = f64::from(app.preferences.space_mouse_speed);
|
||||
@@ -178,15 +177,6 @@ mod tests {
|
||||
crate::navigation::device_camera(cam, motion, seconds, height, !sketch, reverse_zoom);
|
||||
}
|
||||
|
||||
/// A window that has run a frame, with or without focus. A context that has never run one
|
||||
/// reads as unfocused, so the tests run one first, as the app always has.
|
||||
fn window(focused: bool) -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |_| {});
|
||||
output.textures_delta.clear();
|
||||
ctx
|
||||
}
|
||||
|
||||
/// One frame: the 3D mouse hands its motion over, and the modelling viewport applies it.
|
||||
fn frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool {
|
||||
let handed = navigate_frame(app, ctx, mapping, axes, seconds);
|
||||
@@ -349,7 +339,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_frame_moves_the_view_and_keeps_the_next_frame_coming() {
|
||||
let ctx = window(true);
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let before = app.cam;
|
||||
assert!(frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02));
|
||||
@@ -361,20 +351,9 @@ mod tests {
|
||||
assert_eq!(app.cam.distance, still.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_focused_window_moves() {
|
||||
// Two windows side by side (#2077): the cap moves the one with focus and leaves the other.
|
||||
let mut app = app();
|
||||
let before = app.cam;
|
||||
assert!(!frame(&mut app, &window(false), DriverMapping::default(), PUSH, 0.02));
|
||||
assert_eq!(app.cam.distance, before.distance);
|
||||
assert!(frame(&mut app, &window(true), DriverMapping::default(), PUSH, 0.02));
|
||||
assert!(app.cam.distance < before.distance);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_file_picker_holds_the_view_until_it_closes() {
|
||||
let ctx = window(true);
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let slot: crate::picker_test_support::Slot = Default::default();
|
||||
crate::picker_test_support::open(&mut app, &slot);
|
||||
@@ -393,7 +372,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_long_gap_between_frames_moves_as_far_as_a_short_one() {
|
||||
let ctx = window(true);
|
||||
let ctx = egui::Context::default();
|
||||
let (mut short, mut long) = (app(), app());
|
||||
frame(&mut short, &ctx, DriverMapping::default(), PUSH, 0.05);
|
||||
frame(&mut long, &ctx, DriverMapping::default(), PUSH, 30.0);
|
||||
@@ -408,7 +387,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn navigation_off_the_start_page_and_a_hostile_speed_leave_the_view_alone() {
|
||||
let ctx = window(true);
|
||||
let ctx = egui::Context::default();
|
||||
let mut off = app();
|
||||
off.preferences.space_mouse_enabled = false;
|
||||
let mut home = app();
|
||||
|
||||
@@ -2831,11 +2831,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let r = Rect::from_min_size(pos, galley.size() + vec2(16.0, 8.0));
|
||||
painter.rect_filled(r, 4.0, t.overlay);
|
||||
painter.galley(pos + vec2(8.0, 4.0), galley, t.text);
|
||||
// A click opens every message of the run, to read and copy (#2763).
|
||||
crate::scenario::publish_handle("status:chip", r.center());
|
||||
if ui.interact(r, egui::Id::new("sc_status_chip"), Sense::click()).on_hover_text(crate::i18n::tr("messages.chip_hint")).clicked() {
|
||||
app.messages.open = true;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
} else {
|
||||
app.status = None;
|
||||
|
||||
@@ -62,10 +62,7 @@ body in `bodies` and the other in `against`, and `skip_within` leaves out pairs
|
||||
occurrence (`"occurrence"`) or one placed component, its subcomponents included
|
||||
(`"component"`): the parts of a bought component that touch by design. Distances are measured on
|
||||
the exact faces and edges; the meshes only rule out pairs that are too far apart, and the pairs
|
||||
left are checked in parallel. `inspect.interference` remains for the overlaps alone: it clears
|
||||
pairs whose meshes stay apart without intersecting them, intersects the rest in parallel, and
|
||||
returns after `budget_s` seconds (default 240) at most, listing the pairs it had no time for as
|
||||
not checked.
|
||||
left are checked in parallel. `inspect.interference` remains for the overlaps alone.
|
||||
|
||||
**Gear**, **Rack and Pinion** and **Screw** (on the **ASSEMBLE** panel) are motion links whose
|
||||
ratio comes from the parts rather than a typed number:
|
||||
|
||||
@@ -63,7 +63,6 @@ Preferences (File › Preferences) hold the theme, the units of new designs, the
|
||||
visual style and autosave. Graphics › Colour vision gives the colours that carry a meaning (sketch
|
||||
states, axes, bends, analyses, icons) values for a weak red, green or blue sense: Protan, Deutan or
|
||||
Tritan. In a mode, the faces Draft and Accessibility Analysis mark as failing are striped too.
|
||||
Command messages and errors show for a few seconds in the status chip at the bottom left of the view. Click the chip, or choose Help › **Messages…**, for every message of the session: newest first, errors in red, each line selectable, with **Copy all** for a bug report.
|
||||
Help › Keyboard Shortcuts lists every key, and File › Keyboard
|
||||
Shortcuts changes them.
|
||||
|
||||
|
||||
@@ -147,13 +147,12 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
|
||||
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame: a minimised or fully covered window answers at once with `hidden: true` and an error; use `ui.render`) |
|
||||
| `ui.render` | `path`, `width?`, `height?`, `grid?`, `axes?` | Render of the model with the current camera (no window needed), drawn on the app's GPU device with the viewport's pipelines (the reply's `renderer`: `gpu`, or `gpu-software` on a software adapter), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped. The ground grid and the origin's axes are drawn as the window shows them (`ui.set` `showGrid` and `showOrigin`, `hiddenOrigin`); `grid` and `axes` (booleans) say otherwise for this render |
|
||||
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
|
||||
| `ui.messages` | `clear?`: `true` (empty the list), `open?`: bool (show or hide the Messages window) | every status message of this run, oldest first (the last 200; a message repeated in a row is one entry with a `count`): `messages` `[{text, error, at, count}]` (`at` in ms since the Unix epoch), and whether the window is `open` (#2763) |
|
||||
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
|
||||
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them), `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id), `theme_remove` (an imported theme's id), `theme_export: true` (the reply's `theme_file` is the theme on now as a complete file; see [colour themes](book/src/guide/ui-tour.md#colour-themes)), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `pan_reverse_x` (default false: horizontal scrolling pans the other way), `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `extrude_start` (`fixed` (default: a new Extrude opens at 10 mm), `last_used` (the last committed new extrude's distance), `custom` (`extrude_custom_distance`, default `10 mm`); `extrude_last_distance` holds the remembered one), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `show_frame_rate` (default false: frames per second in the view's bottom-right corner; the view redraws every frame while it is on), `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system` or a theme file's id: a built-in one such as `gruvbox-dark`, or an imported one; the reply's `theme_ids` lists them), `theme_import` (a `solidcraft-theme/1` file's JSON or text: adds the theme and turns it on; the reply's `theme_imported` is its id), `theme_remove` (an imported theme's id), `theme_export: true` (the reply's `theme_file` is the theme on now as a complete file; see [colour themes](book/src/guide/ui-tour.md#colour-themes)), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ms`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `ui_font` (an installed font family or a font file for the interface; empty for the built-in font), `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection, AI) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file, `stand_up: false` leaves it as it is ("Leave it": the offer closes and `offer_stand_up` stays on); the offer's buttons are the handles `stand_up:stand`, `stand_up:leave` and `stand_up:never` | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `measure` | `body?` or `items?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: see below. |
|
||||
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
|
||||
| `set_parameter` | `name`, `value` or `expression` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute. `expression` is another name for `value` (the one `parameters.change` uses): give one of them, or both only when they agree; neither, or two that differ, is an error. Reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
|
||||
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
|
||||
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. A window that is minimised or on another macOS Space presents no frame: with no `source` the reply is a model render and says so (`fallback`); `source: "window"` is an error saying why, at once. Agents that don't need the app's chrome can ask for `source: "model"`. |
|
||||
| `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). |
|
||||
| `undo`, `redo` | — | Undo / redo one change. |
|
||||
|
||||
@@ -28,8 +28,6 @@ FULL_FILES = ("Cargo.toml", "Cargo.lock")
|
||||
IGNORED_PREFIXES = ("docs/", "packaging/")
|
||||
# Root files that a crate embeds (include_str!), so they are not ignorable documentation.
|
||||
EMBEDDED = {"ATTRIBUTION.md": "solidcraft-ui-egui"}
|
||||
# Asset folders that only some packages read: the UI embeds the catalogs, xtask checks them.
|
||||
EMBEDDED_PREFIXES = {"assets/locales/": ("solidcraft-ui-egui", "xtask")}
|
||||
|
||||
|
||||
def load_metadata(path):
|
||||
@@ -78,8 +76,6 @@ def select(meta, paths):
|
||||
hit.add(by_dir[max(owners, key=len)])
|
||||
elif path in EMBEDDED:
|
||||
hit.add(EMBEDDED[path])
|
||||
elif any(path.startswith(p) for p in EMBEDDED_PREFIXES):
|
||||
hit.update(next(v for p, v in EMBEDDED_PREFIXES.items() if path.startswith(p)))
|
||||
elif path.startswith(IGNORED_PREFIXES) or ("/" not in path and path.endswith(".md")):
|
||||
continue
|
||||
else:
|
||||
|
||||
@@ -71,14 +71,6 @@ class RealWorkspace(unittest.TestCase):
|
||||
def test_docs_only_selects_nothing(self):
|
||||
self.assertEqual(affected.select(self.meta, ["docs/book/src/dev/testing.md", "CONTRIBUTING.md"]), (False, []))
|
||||
|
||||
def test_translation_selects_the_ui_and_xtask_not_the_kernel(self):
|
||||
full, pk = affected.select(self.meta, ["assets/locales/ja.json", "assets/locales/it.json"])
|
||||
self.assertFalse(full)
|
||||
self.assertIn("solidcraft-ui-egui", pk)
|
||||
self.assertIn("xtask", pk)
|
||||
self.assertNotIn("solidcraft-kernel", pk)
|
||||
self.assertNotIn("solidcraft-engine", pk)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user