Compare commits

..

3 Commits

Author SHA1 Message Date
Phanoob d0a7c74ce5 Merge remote-tracking branch 'upstream/main' into rename-no-rebuild 2026-10-19 15:20:10 +02:00
Phanoob bd81c4622e Renaming a body or a feature builds nothing again (#3044)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:00:23 +02:00
Phanoob 95731a804a claim: #3044
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:00:32 +02:00
37 changed files with 508 additions and 1436 deletions
-5
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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' ||
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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
+1 -1
View File
@@ -20,7 +20,7 @@ concurrency:
jobs:
lint:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
env:
ACTIONLINT_VERSION: 1.7.12
steps:
+2 -2
View File
@@ -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 }}
+9 -9
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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: >-
+1 -1
View File
@@ -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
-2
View File
@@ -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
+150
View File
@@ -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
+170
View File
@@ -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
View File
@@ -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)?;
-130
View File
@@ -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);
}
+46 -4
View File
@@ -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)
+1 -7
View File
@@ -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,
}
-29
View File
@@ -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() {
-37
View File
@@ -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);
}
+9 -81
View File
@@ -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 {
+1 -42
View File
@@ -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, &region, 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);
}
-3
View File
@@ -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
View File
@@ -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 {
-12
View File
@@ -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.
-187
View File
@@ -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;
-73
View File
@@ -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());
}
-27
View File
@@ -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
View File
@@ -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())
}
+1 -1
View File
@@ -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.
+6 -8
View File
@@ -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
View File
@@ -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. |