mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-07 04:12:15 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0a7c74ce5 | |||
| bd81c4622e | |||
| 95731a804a |
@@ -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
|
||||
|
||||
@@ -1406,6 +1406,156 @@ impl Model {
|
||||
self.last_recomputed = recomputed;
|
||||
self.last_restored = restored;
|
||||
}
|
||||
|
||||
/// The model after body `old` was renamed `new`, `before` and `after` being the document
|
||||
/// either side of the rename, without building anything again (#3044): a rename changes no
|
||||
/// geometry, but it changes the features that name the body, and so every fingerprint from
|
||||
/// the body's feature on, and `evaluate` would rebuild all of them. Here each result keeps its
|
||||
/// geometry and takes the new name (its bodies, threads and sheets, and its sketches' links
|
||||
/// where the rename changed them), and its fingerprint is worked out on `after`, so the next
|
||||
/// `evaluate` reuses it.
|
||||
///
|
||||
/// `false`, with the model as it was, where a full evaluation might name something else
|
||||
/// differently: a body whose name was made from `old` at evaluation (`old (2)` or `old'`, a
|
||||
/// piece split off it) and isn't kept in its feature's names, a body already called `new`
|
||||
/// at some step, a component's placed body, a message that names `old`, or results that
|
||||
/// don't match the timeline. The caller then evaluates.
|
||||
pub fn rename_body(&mut self, before: &Document, after: &Document, old: &str, new: &str) -> bool {
|
||||
if self.no_cache || old == new || self.results.len() != after.features.len() || before.features.len() != after.features.len() {
|
||||
return false;
|
||||
}
|
||||
let made_from_old = |name: &str| name.strip_prefix(old).is_some_and(|rest| rest.starts_with(" (") || rest.starts_with('\''));
|
||||
let kept = |b: &ModelBody| after.feature(b.feature).is_some_and(|f| f.body_names.contains(&b.name));
|
||||
let names_old = |m: &Option<String>| m.as_deref().is_some_and(|m| m.contains(old));
|
||||
for (r, f) in self.results.iter().zip(&after.features) {
|
||||
if r.id != f.id || r.stale || names_old(&r.error) || names_old(&r.warning) {
|
||||
return false;
|
||||
}
|
||||
if r.state.bodies.iter().any(|b| b.name == new || b.source_name.is_some() || (made_from_old(&b.name) && !kept(b))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// The links the rename changed, by sketch feature and link id: their new source and names.
|
||||
let mut links: std::collections::HashMap<(u64, &str), &solidcraft_sketch::Link> = std::collections::HashMap::new();
|
||||
for (fb, fa) in before.features.iter().zip(&after.features) {
|
||||
if let (FeatureKind::Sketch { sketch: sb, .. }, FeatureKind::Sketch { sketch: sa, .. }) = (&fb.kind, &fa.kind) {
|
||||
for (lb, la) in sb.links.iter().zip(&sa.links) {
|
||||
if lb.id == la.id && (lb.source != la.source || lb.face_name != la.face_name || lb.edge_name != la.edge_name) {
|
||||
links.insert((fa.id, la.id.as_str()), la);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let rename = |st: &ModelState| -> Option<ModelState> {
|
||||
let sketch_changed = |sk: &SolvedSketch| sk.sketch.links.iter().any(|l| links.contains_key(&(sk.feature, l.id.as_str())));
|
||||
let touched = st.bodies.iter().any(|b| b.name == old)
|
||||
|| st.threads.iter().any(|t| t.body == old)
|
||||
|| st.sheets.iter().any(|sh| sh.body == old)
|
||||
|| st.sketches.iter().any(|sk| sketch_changed(sk));
|
||||
if !touched {
|
||||
return None;
|
||||
}
|
||||
let mut st = st.clone();
|
||||
for b in st.bodies.iter_mut().filter(|b| b.name == old) {
|
||||
b.name = new.to_string();
|
||||
}
|
||||
for t in st.threads.iter_mut().filter(|t| t.body == old) {
|
||||
t.body = new.to_string();
|
||||
}
|
||||
for sh in st.sheets.iter_mut().filter(|sh| sh.body == old) {
|
||||
sh.body = new.to_string();
|
||||
}
|
||||
for sk in &mut st.sketches {
|
||||
if sketch_changed(sk) {
|
||||
let solved = Arc::make_mut(sk);
|
||||
for l in &mut solved.sketch.links {
|
||||
if let Some(la) = links.get(&(solved.feature, l.id.as_str())) {
|
||||
l.source = la.source.clone();
|
||||
l.face_name.clone_from(&la.face_name);
|
||||
l.edge_name.clone_from(&la.edge_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(st)
|
||||
};
|
||||
self.renamed(after, rename);
|
||||
true
|
||||
}
|
||||
|
||||
/// The model after feature `id` was renamed `old` to `new` in `after`, without building
|
||||
/// anything again (#3044), as [`Model::rename_body`]: the feature's result, its solved
|
||||
/// sketch and its construction geometry take the new name. `false`, with the model as it
|
||||
/// was, where an evaluation might name something else differently: a body, thread or sheet
|
||||
/// whose name holds `old`, a message that names it, or results that don't match the timeline.
|
||||
pub fn rename_feature(&mut self, after: &Document, id: u64, old: &str, new: &str) -> bool {
|
||||
if self.no_cache || old == new || old.is_empty() || self.results.len() != after.features.len() {
|
||||
return false;
|
||||
}
|
||||
let names_old = |m: &Option<String>| m.as_deref().is_some_and(|m| m.contains(old));
|
||||
for (r, f) in self.results.iter().zip(&after.features) {
|
||||
if r.id != f.id || r.stale || names_old(&r.error) || names_old(&r.warning) {
|
||||
return false;
|
||||
}
|
||||
let st = &r.state;
|
||||
if st.bodies.iter().any(|b| b.name.contains(old) || b.source_name.as_deref().is_some_and(|n| n.contains(old)))
|
||||
|| st.threads.iter().any(|t| t.body.contains(old))
|
||||
|| st.sheets.iter().any(|sh| sh.body.contains(old))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let rename = |st: &ModelState| -> Option<ModelState> {
|
||||
let touched =
|
||||
st.sketches.iter().any(|sk| sk.feature == id && sk.name == old) || st.construct.iter().any(|c| c.feature == id && c.name == old);
|
||||
if !touched {
|
||||
return None;
|
||||
}
|
||||
let mut st = st.clone();
|
||||
for sk in st.sketches.iter_mut().filter(|sk| sk.feature == id && sk.name == old) {
|
||||
new.clone_into(&mut Arc::make_mut(sk).name);
|
||||
}
|
||||
for c in st.construct.iter_mut().filter(|c| c.feature == id && c.name == old) {
|
||||
new.clone_into(&mut c.name);
|
||||
}
|
||||
Some(st)
|
||||
};
|
||||
self.renamed(after, rename);
|
||||
for r in self.results.iter_mut().filter(|r| r.id == id) {
|
||||
new.clone_into(&mut r.name);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Each result with its state as `rename` makes it (`None`: as it was) and its fingerprint
|
||||
/// worked out on `after`, so the next `evaluate` reuses it; nothing is built.
|
||||
fn renamed(&mut self, after: &Document, rename: impl Fn(&ModelState) -> Option<ModelState>) {
|
||||
let (vals, _) = evaluation_values(after);
|
||||
let steps = StepDigests::of(after, &self.step_digests);
|
||||
let marker = after.marker.unwrap_or(usize::MAX);
|
||||
// Results share their states (a feature that changes nothing passes its input on): each
|
||||
// state is renamed once.
|
||||
let mut done: std::collections::HashMap<*const ModelState, Arc<ModelState>> = std::collections::HashMap::new();
|
||||
let mut prev_fp = 0u64;
|
||||
let mut out = Vec::with_capacity(self.results.len());
|
||||
for (i, (r, f)) in self.results.iter().zip(&after.features).enumerate() {
|
||||
let fp = chain_fingerprint(after, &vals, f, prev_fp, i >= marker, &steps);
|
||||
prev_fp = fp;
|
||||
let state = match done.get(&Arc::as_ptr(&r.state)) {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
let s = rename(&r.state).map_or_else(|| r.state.clone(), Arc::new);
|
||||
done.insert(Arc::as_ptr(&r.state), s.clone());
|
||||
s
|
||||
}
|
||||
};
|
||||
out.push(FeatureResult { fingerprint: fp, state, ..r.clone() });
|
||||
}
|
||||
self.step_digests = steps;
|
||||
self.results = out;
|
||||
self.last_recomputed = 0;
|
||||
self.last_restored = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the design's length unit (mm per unit) among the values (no parameter can be named
|
||||
|
||||
@@ -1182,3 +1182,173 @@ mod tests {
|
||||
assert_eq!(s.visibility.hidden_folders, vec!["o:4/sketches".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3044: a rename builds nothing again. The model the rename leaves is the one a full
|
||||
/// evaluation of the renamed design gives, whether it took the new name in place or, where it
|
||||
/// can't tell, evaluated.
|
||||
#[cfg(test)]
|
||||
mod rename_tests {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a later feature or the view reads from a state: its bodies (name, feature, volume),
|
||||
/// threads, sheets and sketch link sources.
|
||||
fn summary(st: &solidcraft_doc::ModelState) -> String {
|
||||
let bodies: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| format!("{} f{} {:.6}", b.name, b.feature, solidcraft_kernel::measure(&b.body).map(|m| m.volume).unwrap_or(f64::NAN)))
|
||||
.collect();
|
||||
let threads: Vec<&str> = st.threads.iter().map(|t| t.body.as_str()).collect();
|
||||
let sheets: Vec<&str> = st.sheets.iter().map(|t| t.body.as_str()).collect();
|
||||
let links: Vec<String> = st
|
||||
.sketches
|
||||
.iter()
|
||||
.flat_map(|sk| sk.sketch.links.iter().map(move |l| format!("{}:{} {:?} {:?} {:?}", sk.feature, l.id, l.source, l.face_name, l.edge_name)))
|
||||
.collect();
|
||||
let sketches: Vec<String> = st.sketches.iter().map(|sk| format!("{} {}", sk.feature, sk.name)).collect();
|
||||
let construct: Vec<String> = st.construct.iter().map(|c| format!("{} {}", c.feature, c.name)).collect();
|
||||
format!("{bodies:?} {threads:?} {sheets:?} {links:?} {sketches:?} {construct:?}")
|
||||
}
|
||||
|
||||
/// Rename body `old` to `new`; whether that built nothing again. The model then is the one a
|
||||
/// full evaluation (no cache) of the renamed design gives, feature by feature.
|
||||
fn rename(s: &mut Session, old: &str, new: &str) -> bool {
|
||||
renamed_like_evaluated(s, "body.rename", json!({"body": old, "name": new}))
|
||||
}
|
||||
|
||||
/// Rename feature `old` to `new`, as `rename` does a body.
|
||||
fn rename_feature(s: &mut Session, old: &str, new: &str) -> bool {
|
||||
renamed_like_evaluated(s, "timeline.rename", json!({"feature": old, "name": new}))
|
||||
}
|
||||
|
||||
fn renamed_like_evaluated(s: &mut Session, id: &str, p: Value) -> bool {
|
||||
let evaluations = s.evaluations;
|
||||
run(s, id, p);
|
||||
let in_place = s.evaluations == evaluations;
|
||||
let mut fresh = Session::new((*s.doc).clone());
|
||||
fresh.model.no_cache = true;
|
||||
fresh.refresh();
|
||||
assert_eq!(s.model.results.len(), fresh.model.results.len());
|
||||
for (r, f) in s.model.results.iter().zip(&fresh.model.results) {
|
||||
assert_eq!((&r.name, &r.error, &r.warning, r.skipped), (&f.name, &f.error, &f.warning, f.skipped), "feature {}", r.name);
|
||||
assert_eq!(summary(&r.state), summary(&f.state), "after feature {}", r.name);
|
||||
}
|
||||
// And the next evaluation reuses every result.
|
||||
s.refresh();
|
||||
assert_eq!(s.model.last_recomputed, 0, "a rename leaves results the next evaluation rebuilds");
|
||||
in_place
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_the_sensor_enclosures_bodies_builds_nothing() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
assert!(rename(&mut s, "Case", "Shell"));
|
||||
assert!(rename(&mut s, "Lid", "Cover"));
|
||||
// Undo puts the old names back, as it puts back any edit.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.model.state().body("Lid").is_some());
|
||||
}
|
||||
|
||||
/// A body a later feature names, and a sketch on its face whose links name it; the copies a
|
||||
/// pattern made of another body, renamed one by one.
|
||||
#[test]
|
||||
fn a_rename_follows_users_face_sketches_and_copies_without_building() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 30, 0], "operation": "new_body", "body_name": "Y"}));
|
||||
run(&mut s, "solid.move", json!({"bodies": ["Y"], "translate": [0, 0, 5]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": [5, 35, 15]}}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [5, 35], "radius": 2}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": -3, "operation": "cut", "profiles": [{"point": [5, 35]}]}));
|
||||
assert!(rename(&mut s, "Y", "Stand"));
|
||||
let st = s.model.state();
|
||||
let named = st.sketches.iter().flat_map(|sk| &sk.sketch.links).filter(|l| format!("{:?}", l.source).contains("\"Stand\"")).count();
|
||||
assert!(named > 0, "the face sketch's links name the renamed body");
|
||||
assert!(rename(&mut s, "X (3)", "Last"));
|
||||
assert!(rename(&mut s, "X (2)", "Middle"));
|
||||
}
|
||||
|
||||
/// The copies a pattern makes are named after their body when the pattern is evaluated:
|
||||
/// renaming the body renames them, so it evaluates, and gives what an evaluation gives.
|
||||
#[test]
|
||||
fn a_patterned_body_is_renamed_by_evaluating() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "operation": "new_body", "body_name": "X"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20}));
|
||||
assert!(!rename(&mut s, "X", "Base"));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names, ["Base", "Base (2)", "Base (3)"]);
|
||||
}
|
||||
|
||||
/// Features renamed: a sketch later features use, and a construction plane a sketch is on (by
|
||||
/// name). Their results, solved sketches and planes take the new name; nothing is built.
|
||||
#[test]
|
||||
fn renaming_features_builds_nothing() {
|
||||
let mut s = Session::default();
|
||||
let script: Value = serde_json::from_str(include_str!("../../../../examples/acceptance/sensor_enclosure.json")).unwrap();
|
||||
s.run_script(&script).unwrap();
|
||||
let sketch = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().name.clone();
|
||||
assert!(rename_feature(&mut s, &sketch, "Outline"));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Shelf"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "Shelf"}));
|
||||
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": 4}));
|
||||
assert!(rename_feature(&mut s, "Shelf", "Ledge"));
|
||||
}
|
||||
|
||||
/// Folders and components renamed: what they name isn't geometry, and the evaluation that
|
||||
/// follows reuses every result.
|
||||
#[test]
|
||||
fn renaming_folders_and_components_builds_nothing() {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name": "Part"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "First", "body_name": "A"}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "name": "Second", "body_name": "B"}));
|
||||
let folder = run(&mut s, "browser.group", json!({"folder": "bodies", "items": ["A"]}))["group"].as_u64().unwrap();
|
||||
let features = run(&mut s, "timeline.group", json!({"name": "Pair", "features": ["First", "Second"]}))["group"].as_u64().unwrap();
|
||||
for (id, p) in [
|
||||
("browser.rename_group", json!({"group": folder, "name": "Fasteners"})),
|
||||
("timeline.rename_group", json!({"group": features, "name": "Both"})),
|
||||
("component.rename", json!({"component": part, "name": "Bracket"})),
|
||||
] {
|
||||
run(&mut s, id, p);
|
||||
assert_eq!(s.model.last_recomputed, 0, "{id} built something again");
|
||||
}
|
||||
}
|
||||
|
||||
/// A body named like a feature: an evaluation may name it after the feature, so renaming the
|
||||
/// feature evaluates, and gives what an evaluation gives.
|
||||
#[test]
|
||||
fn a_feature_a_body_is_named_like_is_renamed_by_evaluating() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "Block", "body_name": "Block"}));
|
||||
assert!(!rename_feature(&mut s, "Block", "Brick"));
|
||||
}
|
||||
|
||||
/// A body split later: the pieces are named after it when the split is evaluated, so the
|
||||
/// rename evaluates, and gives what an evaluation gives.
|
||||
#[test]
|
||||
fn a_body_split_later_is_renamed_by_evaluating() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 10, "height": 10, "operation": "new_body", "body_name": "Block"}));
|
||||
run(&mut s, "solid.split_body", json!({"body": "Block", "plane": {"origin": [10, 0, 0], "normal": [1, 0, 0]}}));
|
||||
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert!(names.iter().any(|n| n.starts_with("Block (")), "{names:?}");
|
||||
assert!(!rename(&mut s, "Block", "Beam"));
|
||||
}
|
||||
}
|
||||
|
||||
+26
-213
@@ -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")
|
||||
@@ -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), \
|
||||
@@ -533,52 +529,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 +643,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 +663,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 +679,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 +706,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 +716,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 +754,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 +772,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)?;
|
||||
|
||||
@@ -544,133 +544,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);
|
||||
}
|
||||
|
||||
@@ -344,6 +344,26 @@ impl Default for Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a rename command renamed (from its result and the document before it), so the model
|
||||
/// can take the new name without building anything again (#3044).
|
||||
enum Renamed {
|
||||
Body { old: String, new: String },
|
||||
Feature { id: u64, old: String, new: String },
|
||||
}
|
||||
|
||||
impl Renamed {
|
||||
fn of(command: &str, result: &Value, before: &Document) -> Option<Renamed> {
|
||||
let new = result.get("name")?.as_str()?.to_string();
|
||||
match command {
|
||||
"body.rename" => Some(Renamed::Body { old: result.get("body")?.as_str()?.to_string(), new }),
|
||||
"timeline.rename" => {
|
||||
let id = result.get("feature")?.as_u64()?;
|
||||
Some(Renamed::Feature { id, old: before.feature(id)?.name.clone(), new })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Session {
|
||||
pub fn new(mut doc: Document) -> Self {
|
||||
// The session keeps what is hidden (not an undo step); the design file carries it.
|
||||
@@ -576,6 +596,26 @@ impl Session {
|
||||
{
|
||||
self.evaluations += 1;
|
||||
}
|
||||
self.after_evaluation();
|
||||
}
|
||||
|
||||
/// A body or feature renamed (`previous` the document before): the model takes the new
|
||||
/// name without building anything again, or is evaluated where that might not give what an
|
||||
/// evaluation gives (see `Model::rename_body` and `Model::rename_feature`, #3044).
|
||||
fn refresh_renamed(&mut self, previous: &Arc<Document>, renamed: &Renamed) {
|
||||
let in_place = match renamed {
|
||||
Renamed::Body { old, new } => self.model.rename_body(previous, &self.doc, old, new),
|
||||
Renamed::Feature { id, old, new } => self.model.rename_feature(&self.doc, *id, old, new),
|
||||
};
|
||||
if in_place {
|
||||
self.after_evaluation();
|
||||
} else {
|
||||
self.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
/// What follows a new model: a new revision, and what depends on the model brought up to it.
|
||||
fn after_evaluation(&mut self) {
|
||||
self.revision += 1;
|
||||
self.prune_occurrence_state();
|
||||
// Joints follow the geometry their origins snap to.
|
||||
@@ -718,6 +758,7 @@ impl Session {
|
||||
let changed = !Arc::ptr_eq(&self.doc, &before.doc);
|
||||
match r {
|
||||
Ok((v, live_edit)) => {
|
||||
let renamed = Renamed::of(spec.id, &v, &before.doc);
|
||||
let v = if changed { self.with_created(spec.id, params, &before, v) } else { v };
|
||||
if changed {
|
||||
let render_only = self.render_only_change(&before);
|
||||
@@ -729,10 +770,11 @@ impl Session {
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
match live_edit {
|
||||
Some(edit) => self.refresh_live(edit),
|
||||
None if render_only => self.refresh_render(&previous_doc),
|
||||
None => self.refresh(),
|
||||
match (live_edit, renamed) {
|
||||
(Some(edit), _) => self.refresh_live(edit),
|
||||
(None, _) if render_only => self.refresh_render(&previous_doc),
|
||||
(None, Some(renamed)) => self.refresh_renamed(&previous_doc, &renamed),
|
||||
(None, None) => self.refresh(),
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
+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. |
|
||||
|
||||
Reference in New Issue
Block a user