Compare commits

..

2 Commits

Author SHA1 Message Date
bherbruck 0f8aa72732 Release: Windows jobs start at once on 8 vCPU runners
The icons job only copied the committed .ico after a cold build of the CLI, and the Windows
jobs waited for it; package.ps1 falls back to the same file. The Windows builds were 35 minutes
on a 2 vCPU runner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:43:43 -04:00
bherbruck 2c5f8c1776 Build the macOS release binaries per architecture in parallel
The universal macOS job built aarch64 and x86_64 one after the other on a
3-core runner and took 45-50 minutes, the longest job of a release. Each
architecture now builds on its own runner and a small job joins them with lipo
and packages the dmg and CLI zip exactly as before.
2026-10-20 17:43:07 -04:00
215 changed files with 1313 additions and 35647 deletions
+6 -7
View File
@@ -1,5 +1,6 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# CI builds the tests once and runs them in one job on a 32 vCPU runner (.github/workflows/ci.yml).
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
# (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
@@ -33,14 +34,12 @@ slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
# The action matrix sample runs its designs in parallel workers (ACTION_MATRIX_JOBS, set to match in
# ci.yml). Its workers reserve that many of nextest's threads, so the cores are not oversubscribed
# (a pi-case hole that takes 8 s alone went past the matrix's 90 s action limit when they were),
# and it starts first, beside the other slow tests.
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
[[profile.ci.overrides]]
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = 12
priority = 100
threads-required = "num-cpus"
# The slowest tests start first, so a shard does not end with one of them running alone on a
-1
View File
@@ -7,5 +7,4 @@ self-hosted-runner:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-16vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-8vcpu-windows-2025
+48 -117
View File
@@ -8,13 +8,12 @@
# whole workspace, as do pushes to main that change code, the nightly run and Run workflow.
# Documentation, packaging, the other workflows and the GitHub bots' scripts select nothing:
# no native job runs for them, on pull requests or on main.
# Tests are built and run in one job on a big runner, slowest first, starting beside the repository
# checks (no archive to upload and download). A few heavy tests (see .config/nextest.toml) are
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
# and app startup), the reference tests and the doc tests run on main and nightly only, not on
# pull requests (the fast lane, in Select checks); a break there is fixed forward on main. Only
# main saves the build caches; pull requests restore them. sccache is on a Blacksmith sticky disk that
# only main commits.
# main saves the build caches; pull requests restore them. On pull requests the test build and the
# shards start only after the format and repository checks pass.
# A push to main runs only the repository checks when a passing merge-queue run tested that very
# commit, or when nothing a build reads changed since the last push to main whose run passed.
# Otherwise it runs everything.
@@ -30,13 +29,14 @@
# reads `ci` from the newest run of a commit, so a run without it would block a PR that passed.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Every Linux job runs on Blacksmith's Ubuntu 24.04 runners (blacksmith-<N>vcpu-ubuntu-2404), sized
# per job: 32 vCPU for the tests, 16 for Clippy, the archive build (nightly) and the merge queue's, 8 for compiles, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# per job: 16 vCPU for Build tests, Clippy and the merge queue's test shards, 8 for compiles, the
# other test shards, doc and reference tests, 4 for the light checks, 2 for Select checks and ci.
# The Windows shader job runs on Blacksmith's Windows Server 2025 runners (blacksmith-8vcpu-windows-2025);
# the Windows app startup smoke stays on windows-latest, which has a desktop session that presents frames.
# Pull requests, forks included, the merge queue, pushes to main and the nightly run all use them:
# the runners are ephemeral, fork runs get no secrets, and main saves the build caches that every
# other run restores read-only. Nightly and by hand, Build tests also makes an archive the matrix
# jobs run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
# other run restores read-only. The tests are built once (Build tests) into an archive the shards
# run; Blacksmith's image matches ubuntu-24.04, so the archive and the recorded goldens behave the
# same.
name: CI
@@ -178,12 +178,9 @@ jobs:
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
while IFS= read -r -d '' path; do
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
# What a pull request re-checks the goldens for: the kernel, truck and the goldens
# themselves (a re-record is checked before it is queued). The goldens are frozen, so
# on any other change they would only re-check unchanged results; the merge queue,
# main and the nightly run every seed before anything lands.
# What can change a robustness golden: the kernel and the crates a design is built with.
case "$path" in
crates/kernel/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
crates/kernel/*|crates/engine/*|crates/doc/*|crates/sketch/*|crates/geom/*|vendor/truck-*|tests/robustness/*) goldens=true ;;
esac
# What the Windows D3D12 jobs exercise: the GPU crate and its shaders, the app and
# its startup, the viewport's wgpu glue, the vendored egui-wgpu, their scripts.
@@ -290,8 +287,8 @@ jobs:
run: python3 -I scripts/ci/test_affected.py
- name: Merge-of-main detection self-test
run: python3 -I scripts/ci/test_main_merge.py
- name: Claim, triage and kernel-paused bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js scripts/kernel-paused/*.test.js
- name: Claim and triage bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
@@ -340,33 +337,25 @@ jobs:
build-tests:
name: Build tests
# The archive is for the action matrix and the property tests, which run only nightly and by
# hand. Pull requests, the merge queue and main build and run their tests in the `tests` job.
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
# Fail fast on pull requests: the test archive (and so the shards) waits for the repository
# checks, the most common cheap failure; heavy jobs used to run on to the end beside a failed
# format check. Main, nightly and Run workflow build regardless, so the action matrix and the
# property tests that hang off this job keep their signal.
needs: [changes, checks]
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') }}
runs-on: blacksmith-16vcpu-ubuntu-2404
# Fork builds start from main's cache, read-only; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-archive
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
@@ -398,94 +387,63 @@ jobs:
run: sccache --show-stats
tests:
name: Native Linux tests
needs: changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
# Builds the tests and runs them in one job on a big runner: there is no archive to upload
# and download (3 GB on a full run), and the job starts beside the repository checks, not
# after them. nextest hands tests to its threads as they free up, the slowest first (priority
# in .config/nextest.toml), so they start at once and no core idles at the end.
runs-on: blacksmith-32vcpu-ubuntu-2404
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, checks, build-tests]
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (github.event_name != 'pull_request' || needs.checks.result == 'success') && needs.build-tests.result == 'success' }}
runs-on: ${{ github.event_name == 'merge_group' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
# Pull requests test their affected crates in 2 shards; main and the nightly use 4.
shard: ${{ fromJSON(github.event_name == 'pull_request' && '[1, 2]' || '[1, 2, 3, 4]') }}
env:
# Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on
# the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that
# would skip for want of an adapter fails instead.
SOLIDCRAFT_GPU: software
SOLIDCRAFT_REQUIRE_GPU: "1"
ACTION_MATRIX_JOBS: "12"
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install a software Vulkan driver (lavapipe)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-tests
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: native-tests
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
# Each shard runs the archive the Build tests job made.
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
GOLDENS: ${{ needs.changes.outputs.goldens }}
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
# queue and the nightly run every seed.
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml); `default()`
# below is that filter, so a pull request still runs only what it ran before.
base='default()'
if [ "$EVENT_NAME" = pull_request ]; then
# The action matrix sample (about 6 minutes on all cores) runs in the merge queue, on
# main and nightly, not on every pull request push.
base='default() & not (binary(action_matrix) & test(=action_matrix_sample))'
fi
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
filter=()
if [ "$EVENT_NAME" != pull_request ]; then
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
base='not (package(solidcraft-kernel) & binary(boolean_props))'
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
elif [ "$GOLDENS" = true ]; then
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
# also checks every seed but the slow ones, in the same run so they share its cores.
base="(default() & not (binary(action_matrix) & test(=action_matrix_sample))) | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))"
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
fi
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
rm -f "$junit"
# Pull requests test the affected packages (scripts/ci/affected.py).
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
status=0
cargo nextest run "${scope[@]}" --cargo-profile ci --locked --profile ci --no-tests=pass \
--ignore-default-filter -E "$base" || status=1
if [ "$status" != 0 ]; then
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
@@ -493,14 +451,11 @@ jobs:
fi
exit 1
fi
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit
name: junit-${{ matrix.shard }}
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
@@ -605,23 +560,13 @@ jobs:
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-doc
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
@@ -663,23 +608,13 @@ jobs:
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_DIR: /home/runner/.cache/sccache
SCCACHE_CACHE_SIZE: 20G
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
# sccache lives on a Blacksmith sticky disk (an ext4 volume cloned from the last snapshot,
# no 10 GB limit and no download). Only main commits it; pull requests, the merge queue and
# forks mount a clone and never write back, so nothing they build can reach main's cache.
- name: Mount the compiler cache
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
continue-on-error: true
with:
key: ${{ github.repository }}-sccache-oracle
path: /home/runner/.cache/sccache
commit: ${{ github.ref == 'refs/heads/main' && 'on-change' || 'false' }}
# Main saves the cache; every other run restores it, read-only.
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
@@ -811,7 +746,6 @@ jobs:
DOCTESTS: ${{ needs.changes.outputs.doctests }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
EVENT_NAME: ${{ github.event_name }}
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
@@ -826,10 +760,7 @@ jobs:
fi
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
# The archive for the action matrix and property tests exists nightly and by hand only.
if [ "$EVENT_NAME" = schedule ] || [ "$EVENT_NAME" = workflow_dispatch ]; then
test "$BUILD" = success
fi
test "$BUILD" = success
test "$TESTS" = success
fi
if [ "$DOCTESTS" = true ]; then
-42
View File
@@ -1,42 +0,0 @@
# While a new geometry kernel is evaluated, new issues, pull requests and discussions about kernel
# work are labelled `kernel-paused` for a maintainer to answer (CONTRIBUTING.md, "Kernel work is
# paused"). The logic is in scripts/kernel-paused/ (tested in ci.yml).
name: Kernel paused
on:
issues:
types: [opened]
# pull_request_target so PRs from forks can be labelled. It runs the base branch's
# scripts/kernel-paused and only lists the PR's changed paths; the PR's code is never checked out.
pull_request_target:
types: [opened]
discussion:
types: [created]
permissions:
contents: read # to check out scripts/kernel-paused
issues: write
pull-requests: read # to list a PR's changed paths
discussions: write
concurrency:
group: kernel-paused-${{ github.event.issue.number || github.event.pull_request.number || github.event.discussion.number }}
cancel-in-progress: false
jobs:
paused:
name: Kernel paused bot
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/kernel-paused
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const paused = require('./scripts/kernel-paused/kernel-paused.js');
const done = context.eventName === 'discussion'
? await paused.handleDiscussion({ github, context })
: await paused.handleOpened({ github, context });
core.info(done);
+53 -37
View File
@@ -8,7 +8,7 @@
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# macOS universal .dmg, and the CLI as a .zip (both archs built in parallel) packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
@@ -83,28 +83,6 @@ jobs:
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
path: icons
if-no-files-found: error
linux:
name: Linux ${{ matrix.arch }}
needs: version
@@ -293,11 +271,19 @@ jobs:
path: dist/release/*
if-no-files-found: error
macos:
name: macOS universal
# The two halves of the universal binary build in parallel on their own runners (each alone
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
macos-build:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
strategy:
fail-fast: false
matrix:
include:
- { arch: aarch64, target: aarch64-apple-darwin }
- { arch: x86_64, target: x86_64-apple-darwin }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
@@ -305,12 +291,45 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-macos-universal
- name: Build and package (dmg, CLI zip; the dmg is mounted and checked)
run: packaging/macos/package.sh --arch universal
key: release-macos-${{ matrix.arch }}
- name: Build
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
# Not named release-*: the release job collects every release-* artifact as an asset.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: macos-bin-${{ matrix.arch }}
path: |
target/${{ matrix.target }}/release/solidcraft
target/${{ matrix.target }}/release/solidcraft-cli
target/${{ matrix.target }}/release/solvecraft-cli
if-no-files-found: error
macos:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-aarch64
path: target/aarch64-apple-darwin/release
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: macos-bin-x86_64
path: target/x86_64-apple-darwin/release
- name: Join, package and check (dmg, CLI zip; the dmg is mounted and checked)
run: |
# Artifacts don't keep the executable bit.
chmod +x target/*-apple-darwin/release/solidcraft target/*-apple-darwin/release/solidcraft-cli target/*-apple-darwin/release/solvecraft-cli
packaging/macos/package.sh --arch universal --skip-build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-macos
@@ -319,9 +338,12 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
needs: version
if: needs.version.outputs.build == 'true'
runs-on: windows-latest
# An 8 vCPU runner: the release build was 35 minutes on a 2 vCPU windows-latest and is the
# longest job of the release. The installer icon is the committed
# assets/icons/app/windows/exports/solidcraft.ico (package.ps1's default), so nothing waits.
runs-on: blacksmith-8vcpu-windows-2025
strategy:
fail-fast: false
matrix:
@@ -334,10 +356,6 @@ jobs:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: icons
path: icons
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -351,8 +369,6 @@ jobs:
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
+1 -18
View File
@@ -57,23 +57,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| docs/book/src/generated/tool-preview.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/tool-preview.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/step-conformance/cone_point.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cone_point_vertex.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_inch.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_placed.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_seam.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/cylinder_twice.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/dome.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/dome_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/far_cylinder.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/grooved_shaft.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/plate_100_holes.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/sheet_disc.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/tiny_pin.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/torus_ring_nurbs.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/step-conformance/washer.step | step-conformance contributors | generated from formulas by https://github.com/dan335/step-conformance (corpus/generated, 96f30bb); import regression fixture | CC0-1.0 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
@@ -94,6 +77,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
@@ -118,7 +102,6 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/cs.json | potuzai | Czech translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/da.json | SolidCraft contributors | Original Danish translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en-GB.json | SolidCraft contributors | English (UK): the English catalog's messages whose spelling differs, in UK spelling | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/es.json | jawler | Spanish (Spain) translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
+1 -10
View File
@@ -15,15 +15,6 @@ Before choosing a task, check the [command inventory](docs/features.md) and
Questions, ideas or want to say what you're working on? Join the [Discord](https://discord.gg/FtU4NQNVDH).
## Kernel work is paused
We're evaluating a new geometry kernel. Until that's decided, please don't open issues, pull
requests or discussions about kernel work: booleans, fillets, chamfers, shell, offsets, sweeps and
lofts, threads and holes, STEP/IGES import and export, tessellation, or anything else that changes
`crates/kernel/`. The pinned issue on the tracker explains it. New ones get the `kernel-paused`
label (`.github/workflows/kernel-paused.yml`) and a maintainer answers them. Crashes and data loss
are still taken as issues, and everything outside the kernel carries on as normal.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
@@ -208,7 +199,7 @@ Check that validation after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests that change the kernel, truck or the goldens run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
Generated
+17 -18
View File
@@ -4162,7 +4162,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4193,14 +4193,13 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.18.3"
version = "0.18.1"
[[package]]
name = "solidcraft-cli"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"image",
"log",
"serde_json",
"solidcraft-engine",
"solidcraft-gpu",
@@ -4209,7 +4208,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4218,7 +4217,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"log",
"serde",
@@ -4235,7 +4234,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"log",
"schemars",
@@ -4254,7 +4253,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"serde",
"serde_json",
@@ -4263,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"log",
"naga",
@@ -4280,7 +4279,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4303,7 +4302,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -4323,7 +4322,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4332,7 +4331,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"image",
"moxcms",
@@ -4346,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"serde",
"serde_json",
@@ -4357,14 +4356,14 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-ui-egui"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"arboard",
"egui",
@@ -4391,7 +4390,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"eframe",
"egui",
@@ -5921,7 +5920,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.18.3"
version = "0.18.1"
dependencies = [
"serde_json",
"sha2 0.10.9",
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.18.3"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
-1
View File
@@ -23,7 +23,6 @@ path = "src/alias.rs"
solidcraft-engine = { workspace = true }
solidcraft-gpu = { workspace = true }
solidcraft-mcp = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[dev-dependencies]
-49
View File
@@ -1,49 +0,0 @@
//! What the engine logs (`log::info!`, `log::debug!`: why a body has no display plan, why a save
//! left its model cache out), written to stderr. Off unless asked for, so the CLI's output and
//! the scripts reading it stay as they are: `--debug` shows SolidCraft's own debug messages (and
//! other crates' warnings, whose debug output is large), `RUST_LOG=error|warn|info|debug|trace`
//! sets one level for every crate.
use log::{LevelFilter, Log, Metadata, Record};
struct Stderr {
/// The level for SolidCraft's crates.
ours: LevelFilter,
/// The level for every other crate.
others: LevelFilter,
}
impl Log for Stderr {
fn enabled(&self, m: &Metadata) -> bool {
m.level() <= if m.target().starts_with("solidcraft") { self.ours } else { self.others }
}
fn log(&self, r: &Record) {
if self.enabled(r.metadata()) {
use std::io::Write as _;
// A closed stderr is no reason to stop.
let _ = writeln!(std::io::stderr().lock(), "[{} {}] {}", r.level().as_str().to_ascii_lowercase(), r.target(), r.args());
}
}
fn flush(&self) {}
}
/// Removes `--debug` from `args`, wherever it is (every subcommand takes it), and installs the
/// logger when it or `RUST_LOG` asks for one.
pub fn init(args: &mut Vec<String>) {
let before = args.len();
args.retain(|a| a != "--debug");
let debug = args.len() != before;
let env = std::env::var("RUST_LOG").ok().and_then(|v| v.trim().parse::<LevelFilter>().ok());
let (ours, others) = match (debug, env) {
(false, Some(all)) => (all, all),
(true, Some(all)) => (all.max(LevelFilter::Debug), all),
(true, None) => (LevelFilter::Debug, LevelFilter::Warn),
(false, None) => return,
};
// Installed once, for the life of the process.
if log::set_logger(Box::leak(Box::new(Stderr { ours, others }))).is_ok() {
log::set_max_level(ours.max(others));
}
}
+1 -7
View File
@@ -17,8 +17,6 @@
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//!
//! --debug (with any command) SolidCraft's debug log on stderr (or RUST_LOG=LEVEL)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -26,7 +24,6 @@
mod bench_agent;
mod crashes;
mod fuzz;
mod logger;
mod oracle;
mod preview;
mod recipe;
@@ -85,13 +82,10 @@ const USAGE: &str = "usage:
lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults
to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter)
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
--debug, with any command: what SolidCraft logs (why a body has no display plan, why a save left its
model cache out), on stderr; RUST_LOG=error|warn|info|debug|trace sets one level for every crate
";
fn main() -> ExitCode {
let mut args: Vec<String> = std::env::args().skip(1).collect();
logger::init(&mut args);
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
+9 -237
View File
@@ -3,10 +3,9 @@
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_engine::MAX_LINE;
@@ -179,137 +178,11 @@ struct Pending {
reply: Receiver<Value>,
}
/// Every request the app has been sent and not answered yet, from every connection, for
/// `control.running` (#3654). Each connection's own thread answers that, so it never waits behind
/// a running request.
struct Running {
list: Mutex<Vec<InFlight>>,
connections: AtomicU64,
keys: AtomicU64,
}
/// What `control.running` says about a request: never its parameters beyond the command id.
struct InFlight {
key: u64,
connection: u64,
id: Value,
method: String,
command: Option<&'static str>,
steps: Option<usize>,
since: Instant,
started: Arc<AtomicBool>,
}
/// The app's requests, from both of its listeners.
static RUNNING: Running = Running::new();
impl Running {
const fn new() -> Self {
Running { list: Mutex::new(Vec::new()), connections: AtomicU64::new(0), keys: AtomicU64::new(0) }
}
fn lock(&self) -> MutexGuard<'_, Vec<InFlight>> {
self.list.lock().unwrap_or_else(PoisonError::into_inner)
}
/// List `req`, sent on `connection`, until the returned ticket is dropped.
fn add(&'static self, connection: u64, id: &Value, req: &ControlRequest) -> Ticket {
let key = self.keys.fetch_add(1, Ordering::Relaxed);
// Ids and methods come from the client: only short ones are repeated to the others.
let id = match id {
Value::Number(_) => id.clone(),
Value::String(s) if s.len() <= 64 => id.clone(),
_ => Value::Null,
};
let p = &req.params;
let command = match req.method.as_str() {
"engine.execute" | "command" => p.get("command").and_then(Value::as_str).and_then(solidcraft_engine::find_command).map(|c| c.id),
_ => None,
};
let steps = match req.method.as_str() {
"engine.batch" | "engine.script" => p.get("commands").and_then(Value::as_array).map(Vec::len),
_ => None,
};
let method = req.method.chars().take(64).collect();
let started = req.started.clone();
self.lock().push(InFlight { key, connection, id, method, command, steps, since: Instant::now(), started });
Ticket { running: self, key }
}
/// The `control.running` result, as `asker`'s connection sees it.
fn result(&self, asker: u64) -> Value {
let rows: Vec<Value> = self
.lock()
.iter()
.map(|e| {
let mut r = json!({
"connection": e.connection,
"id": e.id,
"method": e.method,
"running_s": (e.since.elapsed().as_secs_f64() * 10.0).round() / 10.0,
"queued": !e.started.load(Ordering::Relaxed),
});
if let Some(c) = e.command {
r["command"] = json!(c);
}
if let Some(n) = e.steps {
r["steps"] = json!(n);
}
if e.connection == asker {
r["self"] = json!(true);
}
r
})
.collect();
json!({"running": rows})
}
}
/// A listed request; dropped when its reply arrives.
struct Ticket {
running: &'static Running,
key: u64,
}
impl Drop for Ticket {
fn drop(&mut self) {
let key = self.key;
self.running.lock().retain(|e| e.key != key);
}
}
/// The reply to a timed-out request, received on a thread of its own so the request stays listed
/// until the app answers, also once its connection has closed (#3654).
fn watch(reply: Receiver<Value>, ticket: Ticket) -> Receiver<Value> {
let (late_tx, late_rx) = channel();
// A small thread per timed-out request (each connection has at most one). If none can be
// made, the reply is lost and `control.pending` says the app ended the request without one.
// The reply is passed on before the request leaves the list: once `control.running` no longer
// shows it, its connection's `control.pending` has it.
let _ = std::thread::Builder::new().stack_size(64 * 1024).spawn(move || {
if let Ok(v) = reply.recv() {
let _ = late_tx.send(v);
}
drop(late_tx);
drop(ticket);
});
late_rx
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool) {
serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING);
serve_with(stream, token, tx, ctx, patient, WAITS);
}
fn serve_with(
stream: TcpStream,
token: &str,
tx: Sender<ControlRequest>,
ctx: egui::Context,
patient: bool,
waits: Waits,
running: &'static Running,
) {
let connection = running.connections.fetch_add(1, Ordering::Relaxed) + 1;
fn serve_with(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, patient: bool, waits: Waits) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
@@ -343,16 +216,13 @@ fn serve_with(
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A request that timed out may have finished since (#1618). `control.running` only
// looks, so it leaves that reply where it is.
let late = pending.as_ref().filter(|_| method != "control.running").and_then(|p| match p.reply.try_recv() {
// A request that timed out may have finished since (#1618).
let late = pending.as_ref().and_then(|p| match p.reply.try_recv() {
Ok(reply) => Some(reply),
Err(TryRecvError::Disconnected) => Some(json!({"ok": false, "error": "the app ended the request without a reply"})),
Err(TryRecvError::Empty) => None,
});
let mut r = if method == "control.running" {
json!({"ok": true, "result": running.result(connection)})
} else if method == "control.pending" {
let mut r = if method == "control.pending" {
match (pending.take(), late) {
(None, _) => json!({"ok": true, "result": {"pending": null}}),
(Some(p), Some(reply)) => json!({"ok": true, "result": {"pending": p.id, "method": p.method, "done": true, "reply": reply}}),
@@ -373,7 +243,6 @@ fn serve_with(
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = if patient || method == "engine.batch" { waits.long } else { waits.short };
let (req, rrx) = ControlRequest::new(method.clone(), params);
let ticket = running.add(connection, &id, &req);
if tx.send(req).is_err() {
break;
}
@@ -382,7 +251,7 @@ fn serve_with(
Ok(r) => r,
Err(_) => {
let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")});
pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) });
pending = Some(Pending { id: id.clone(), method, reply: rrx });
r
}
}
@@ -438,10 +307,9 @@ mod tests {
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_millis(100) };
static RUNNING: Running = Running::new();
std::thread::spawn(move || {
if let Some(stream) = listener.incoming().flatten().next() {
serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING);
serve_with(stream, token, tx, egui::Context::default(), false, waits);
}
});
let s = TcpStream::connect(addr).unwrap();
@@ -481,102 +349,6 @@ mod tests {
assert_eq!(*ran.lock().unwrap(), ["slow", "ui.view"], "the busy request never reached the app");
}
/// #3654: `control.running` answers on every connection, at once, with what any connection
/// has the app running or queued (command ids, never parameters); a timed-out request stays
/// listed until the app answers it, and an idle app lists nothing.
#[test]
fn every_connection_sees_what_the_app_is_running() {
static RUNNING: Running = Running::new();
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
// The window: one request at a time, as `drain_control` takes them; the export is held
// until the test lets it go.
let (gate_tx, gate_rx) = channel::<()>();
std::thread::spawn(move || {
for req in rx {
req.started.store(true, Ordering::Relaxed);
if req.method == "engine.execute" {
let _ = gate_rx.recv();
}
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
}
});
let waits = Waits { short: Duration::from_millis(100), long: Duration::from_secs(60) };
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve_with(stream, token, tx, egui::Context::default(), false, waits, &RUNNING));
}
});
let open = || {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
let mut out = s.try_clone().unwrap();
let mut lines = BufReader::new(s);
move |v: Value| -> Value {
writeln!(out, "{v}").unwrap();
let mut l = String::new();
lines.read_line(&mut l).unwrap();
serde_json::from_str(&l).unwrap()
}
};
let running = |ask: &mut dyn FnMut(Value) -> Value, until: &dyn Fn(&[Value]) -> bool| {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let r = ask(json!({"id": 0, "method": "control.running", "token": token}));
let rows = r["result"]["running"].as_array().cloned().unwrap_or_default();
if until(&rows) || Instant::now() > deadline {
return (r, rows);
}
std::thread::sleep(Duration::from_millis(10));
}
};
let mut watcher = open();
let (r, rows) = running(&mut watcher, &|_| true);
assert!(r["ok"] == true && rows.is_empty(), "idle: {r}");
let mut export = open();
let path = "/private/part.step";
let params = json!({"command": "file.export", "params": {"path": path}});
let r = export(json!({"id": 7, "method": "engine.execute", "params": params, "token": token}));
assert_eq!(r["running"], true, "{r}");
// A batch from another connection waits behind the export.
let mut batch = open();
let queued = std::thread::spawn(move || {
let steps = json!([{"command": "solid.box"}, {"command": "solid.box"}]);
batch(json!({"id": "b", "method": "engine.batch", "params": {"commands": steps}, "token": token}))
});
// The window may not have taken the export yet, even though it timed out.
let (r, rows) = running(&mut watcher, &|rows| rows.len() == 2 && rows[0]["queued"] == false);
assert_eq!(rows.len(), 2, "{r}");
let (e, b) = (&rows[0], &rows[1]);
assert_eq!(
(&e["id"], &e["method"], &e["command"], &e["queued"]),
(&json!(7), &json!("engine.execute"), &json!("file.export"), &json!(false)),
"{r}"
);
assert!(e["running_s"].as_f64().is_some_and(|s| s >= 0.1), "{r}");
assert_eq!((&b["id"], &b["method"], &b["steps"], &b["queued"]), (&json!("b"), &json!("engine.batch"), &json!(2), &json!(true)), "{r}");
assert_ne!(e["connection"], b["connection"], "{r}");
assert!(rows.iter().all(|row| row.get("self").is_none()), "{r}");
assert!(!r.to_string().contains(path), "parameters stay with their connection: {r}");
// The connection whose request timed out may ask too, and sees its own marked.
let r = export(json!({"id": 8, "method": "control.running"}));
let own: Vec<&Value> = r["result"]["running"].as_array().unwrap().iter().filter(|row| row["self"] == true).collect();
assert_eq!(own.len(), 1, "{r}");
assert_eq!(own[0]["command"], "file.export", "{r}");
gate_tx.send(()).unwrap();
assert_eq!(queued.join().unwrap()["result"], "engine.batch");
let (r, rows) = running(&mut watcher, &|rows| rows.is_empty());
assert!(rows.is_empty(), "done: {r}");
// Asking `control.running` did not take the export's reply from its connection.
let r = export(json!({"id": 9, "method": "control.pending"}));
assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}");
}
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
/// need not be repeated on that connection.
@@ -141,13 +141,18 @@ new false
normal false
offset false
offset_face false
offset_one_side false
offset_symmetric false
offset_two_sides false
open false
orbit false
origin false
pan false
params false
paste false
pattern_center false
pattern_circ false
pattern_flip false
pattern_path false
pattern_rect false
perforate false
-27
View File
@@ -1,27 +0,0 @@
{
"Center X": "Centre X",
"Center Y": "Centre Y",
"Center of mass": "Centre of mass",
"Customize Toolbar": "Customise Toolbar",
"Mass Center": "Mass Centre",
"Modeled": "Modelled",
"cmd.construct_point_center": "Point At Centre Of Circle/Sphere/Torus",
"cmd.inspect_center_of_mass": "Centre of Mass",
"cmd.sketch_arc_center_point": "Centre Point Arc",
"cmd.sketch_circle_center": "Centre Diameter Circle",
"cmd.sketch_rectangle_center": "Centre Rectangle",
"cmd.sketch_slot_arc_center": "Centre Point Arc Slot",
"cmd.sketch_slot_center_point": "Centre Point Slot",
"cmd.sketch_slot_center_to_center": "Centre to Centre Slot",
"pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.",
"render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.",
"sketch_text.align_center": "Centre",
"ui.center": "Centre",
"ui.centerline": "Centreline",
"ui.customizeToolbar": "Customise Toolbar…",
"ui.design_properties.license": "Licence",
"ui.design_properties.organization": "Organisation",
"ui.space_mouse_step_pull": "Pull the cap towards you, hold it, then let go.",
"ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)",
"update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check."
}
-8
View File
@@ -855,12 +855,6 @@
"help.licences": "Licences",
"help.mit": "MIT licence",
"help.notice": "NOTICE",
"help.solid.chamfer": "Cuts the selected edges with a flat bevel of the distance you set. Use it to break sharp edges or to ease the entry of a pin or screw.",
"help.solid.extrude": "Pushes a sketch profile into 3D along a distance, adding or removing material. Use it to turn a flat outline into a block, a boss or a pocket.",
"help.solid.fillet": "Rounds the selected edges with a smooth circular blend of the radius you set. Use it to soften sharp edges for strength, comfort or looks.",
"help.solid.hole": "Drills a round hole of the diameter you set at a chosen point on a face. Use it for screw clearances, pins and other simple round openings.",
"help.solid.revolve": "Spins a sketch profile around an axis to make a round solid. Use it for shafts, knobs, bowls and any part that is round about a centre line.",
"help.solid.shell": "Hollows out a solid, leaving walls of the thickness you set, and can open the faces you pick. Use it for boxes, covers and other thin-walled parts.",
"help.third_party": "Third-party licences",
"home.documentation": "Documentation",
"home.in_this_browser": "in this browser",
@@ -1209,8 +1203,6 @@
"ui.agent_chat_view_attached": "View attached",
"ui.agent_cursor": "Agent cursor",
"ui.agent_cursor_speed": "Agent cursor speed",
"ui.agent_off_thread": "Keep the window responsive while agents run commands",
"ui.agent_off_thread_note": "An agent's slow commands (from the AI panel or an MCP client) compute beside the window: you can keep turning and inspecting the view, and stop one from the chip in the view. Off: each runs on the window's thread and the window waits for it.",
"ui.ai_add": "Add",
"ui.ai_add_agent": "Add agent…",
"ui.ai_add_agent_title": "Add agent",
-6
View File
@@ -855,12 +855,6 @@
"help.licences": "Licences",
"help.mit": "Licence MIT",
"help.notice": "NOTICE",
"help.solid.chamfer": "Coupe les arêtes sélectionnées par un biseau plat de la distance indiquée. À utiliser pour casser des arêtes vives ou faciliter l'entrée d'une goupille ou d'une vis.",
"help.solid.extrude": "Pousse le profil d'une esquisse en 3D sur une distance, en ajoutant ou en retirant de la matière. À utiliser pour transformer un contour plat en bloc, en bossage ou en poche.",
"help.solid.fillet": "Arrondit les arêtes sélectionnées par un raccord circulaire lisse du rayon indiqué. À utiliser pour adoucir des arêtes vives par souci de solidité, de confort ou d'aspect.",
"help.solid.hole": "Perce un trou rond du diamètre indiqué en un point choisi d'une face. À utiliser pour les passages de vis, les goupilles et autres ouvertures rondes simples.",
"help.solid.revolve": "Fait tourner le profil d'une esquisse autour d'un axe pour obtenir un solide de révolution. À utiliser pour les arbres, boutons, bols et toute pièce ronde autour d'une ligne centrale.",
"help.solid.shell": "Évide un solide en laissant des parois de l'épaisseur indiquée, et peut ouvrir les faces choisies. À utiliser pour les boîtes, couvercles et autres pièces à parois minces.",
"help.third_party": "Licences tierces",
"home.documentation": "Documentation",
"home.in_this_browser": "dans ce navigateur",
-8
View File
@@ -855,12 +855,6 @@
"help.licences": "ライセンス",
"help.mit": "MITライセンス",
"help.notice": "NOTICE",
"help.solid.chamfer": "選択した辺を、指定した距離で平らに斜めに削ります。鋭い角を落とすときや、ピンやねじを入れやすくするときに使います。",
"help.solid.extrude": "スケッチの輪郭を指定した距離だけ立体方向に押し出し、材料を足したり削ったりします。平面の図形をブロック、突起、ポケットにするときに使います。",
"help.solid.fillet": "選択した辺を、指定した半径のなめらかな円弧で丸めます。強度や持ちやすさ、見た目のために鋭い角を和らげるときに使います。",
"help.solid.hole": "面上の指定した位置に、指定した直径の丸い穴をあけます。ねじのクリアランスやピン穴など、単純な丸い開口に使います。",
"help.solid.revolve": "スケッチの輪郭を軸のまわりに回転させ、丸い立体を作ります。軸、つまみ、ボウルなど、中心線まわりに丸い部品に使います。",
"help.solid.shell": "立体の中をくり抜き、指定した厚さの壁を残します。選んだ面を開口にすることもできます。箱、カバーなどの薄肉の部品に使います。",
"help.third_party": "サードパーティライセンス",
"home.documentation": "ドキュメント",
"home.in_this_browser": "このブラウザ内",
@@ -1209,8 +1203,6 @@
"ui.agent_chat_view_attached": "ビューを添付済み",
"ui.agent_cursor": "エージェントカーソル",
"ui.agent_cursor_speed": "エージェントカーソル速度",
"ui.agent_off_thread": "エージェントのコマンド実行中もウィンドウを操作可能にする",
"ui.agent_off_thread_note": "エージェント(AI パネルまたは MCP クライアント)の時間のかかるコマンドをウィンドウとは別に計算します。実行中もビューの回転や確認ができ、ビュー内の表示から停止できます。オフ:各コマンドはウィンドウのスレッドで実行され、終わるまでウィンドウは待ちます。",
"ui.ai_add": "追加",
"ui.ai_add_agent": "エージェントを追加…",
"ui.ai_add_agent_title": "エージェントを追加",
-8
View File
@@ -855,12 +855,6 @@
"help.licences": "许可证",
"help.mit": "MIT 许可证",
"help.notice": "NOTICE",
"help.solid.chamfer": "用指定的距离在所选边上切出一个平直的斜面。用于去除锐边,或使销钉、螺钉更容易插入。",
"help.solid.extrude": "将草图轮廓按指定距离拉伸为三维形体,可以增加或去除材料。用于把平面轮廓变成方块、凸台或凹槽。",
"help.solid.fillet": "用指定半径的平滑圆弧将所选的边倒圆。用于出于强度、手感或外观的考虑而柔化锐边。",
"help.solid.hole": "在面上的指定位置钻出指定直径的圆孔。用于螺钉过孔、销孔等简单的圆形开口。",
"help.solid.revolve": "将草图轮廓绕一条轴旋转,生成圆形的实体。用于轴、旋钮、碗等围绕中心线呈圆形的零件。",
"help.solid.shell": "将实体掏空,留下指定厚度的壁,还可以打开所选的面。用于盒子、盖子等薄壁零件。",
"help.third_party": "第三方许可证",
"home.documentation": "文档",
"home.in_this_browser": "在此浏览器中",
@@ -1209,8 +1203,6 @@
"ui.agent_chat_view_attached": "已附加视图",
"ui.agent_cursor": "代理光标",
"ui.agent_cursor_speed": "代理光标速度",
"ui.agent_off_thread": "代理运行命令时保持窗口响应",
"ui.agent_off_thread_note": "代理(来自 AI 面板或 MCP 客户端)的耗时命令在窗口之外计算:运行时仍可旋转和查看视图,并可在视图中的提示上停止它。关闭:每条命令在窗口线程上运行,窗口会等待其完成。",
"ui.ai_add": "添加",
"ui.ai_add_agent": "添加智能体…",
"ui.ai_add_agent_title": "添加智能体",
+2 -7
View File
@@ -7,13 +7,8 @@ pub fn state_in_face_anchor_frame<'a>(doc: &Document, state: &'a ModelState, anc
let raw = state.body(&anchor.target.body).ok_or_else(|| DocError::Unknown(format!("anchored body `{}`", anchor.target.body)))?;
let relative = anchor.source_to_author(doc, author, raw)?;
let mut output = crate::state_in_frame_at(doc, state, author, frame).into_owned();
// In place, the body is the model's own, display mesh included: a sketch projecting the
// face's edges meshes it once for the features after it too, rather than a copy.
let mut body = if crate::is_identity(&relative) {
raw.clone()
} else {
ModelBody::new(raw.name.clone(), solidcraft_kernel::transform_matrix(&raw.body, relative)?, raw.feature)
};
let shape = if crate::is_identity(&relative) { raw.body.clone() } else { solidcraft_kernel::transform_matrix(&raw.body, relative)? };
let mut body = ModelBody::new(raw.name.clone(), shape, raw.feature);
body.component = raw.component;
body.transferred = raw.transferred;
body.source_name = raw.source_name.clone();
-3
View File
@@ -180,9 +180,6 @@ fn shell_planar_format13_keeps_v12_documents_and_refuses_future() {
assert_eq!(current["format"], crate::format::format_string(crate::format::FORMAT));
current["format"] = json!("solidcraft/12");
crate::format::pieces_in_shell_order(&mut legacy["features"]);
for f in legacy["features"].as_array_mut().into_iter().flatten() {
f["kernel_piece_order"] = json!(true);
}
assert_eq!(legacy, current, "the migration preserves every prior field");
legacy["format"] = json!(crate::format::format_string(crate::format::FORMAT + 1));
assert!(crate::format::read(&legacy.to_string()).is_err());
-16
View File
@@ -950,15 +950,6 @@ pub enum FeatureKind {
/// Normals the same way instead of facing each other.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// A plane the bodies move with (an origin or construction plane): its origin and normal
/// are the `from` point and normal, worked out again whenever the plane moves.
#[serde(default, skip_serializing_if = "Option::is_none")]
from_plane: Option<PlaneRef>,
/// A plane the bodies move onto: after turning to meet it (when `from` has a normal) the
/// `from` point is brought onto the plane along its normal, so the plane may move and the
/// bodies follow. Replaces `to`.
#[serde(default, skip_serializing_if = "Option::is_none")]
to_plane: Option<PlaneRef>,
},
/// A helical coil: a circular or square section swept `turns` times about `axis`, rising
/// `pitch` per turn, on a helix of `diameter` (the section's centre, or its inside or outside).
@@ -2329,12 +2320,6 @@ pub struct Feature {
/// where newer ones number them after the first name (`Ribs (4)`, #373).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub shell_piece_order: bool,
/// The pieces of a face this feature splits are numbered in the kernel's order and by the
/// index of the face they came from, as builds before #3902 numbered them, instead of by
/// place. Set on the features of designs saved before then, whose stored `#k` face names
/// mean that numbering.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub kernel_piece_order: bool,
#[serde(flatten)]
pub kind: FeatureKind,
}
@@ -3036,7 +3021,6 @@ impl Document {
face_names: Vec::new(),
participants: Vec::new(),
shell_piece_order: false,
kernel_piece_order: false,
kind,
};
self.place_feature(f);
+65 -583
View File
File diff suppressed because it is too large Load Diff
+5 -21
View File
@@ -116,35 +116,19 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
}
Ok(())
}
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip, from_plane, to_plane } => {
// A plane on either side is worked out now, so the bodies follow it when it moves.
let (mut from, mut from_normal) = (from.0, from_normal.and_then(|n| n.normalized()));
if let Some(p) = from_plane {
let pl = doc.resolve_plane_in(vals, p, 0, Some(&*st))?;
(from, from_normal) = (pl.origin, Some(pl.normal()));
}
let target = to_plane.as_ref().map(|p| doc.resolve_plane_in(vals, p, 0, Some(&*st))).transpose()?;
let (to, to_normal) = match &target {
Some(pl) => (pl.origin, Some(pl.normal())),
None => (to.0, to_normal.and_then(|n| n.normalized())),
};
FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => {
let mut m = translation(Vec3::ZERO);
if let (Some(a), Some(b)) = (from_normal, to_normal) {
if let (Some(a), Some(b)) = (from_normal.and_then(|n| n.normalized()), to_normal.and_then(|n| n.normalized())) {
// Turn `a` onto −b (faces meet) or b (flipped), about the `from` point.
let want = if *flip { b } else { b * -1.0 };
let axis = a.cross(want);
let ang = axis.len().atan2(a.dot(want));
let axis = axis.normalized().unwrap_or_else(|| a.any_perp());
if ang.abs() > 1e-12 {
m = rotation(from, axis, ang);
m = rotation(from.0, axis, ang);
}
}
// Onto a plane the point goes straight along the plane's normal (nothing slides
// across it); onto a point it lands on it.
let d = match (&target, to_normal) {
(Some(_), Some(n)) => n * (to - from).dot(n),
_ => to - from,
};
let d = to.0 - from.0;
m[3][0] += d.x;
m[3][1] += d.y;
m[3][2] += d.z;
@@ -627,7 +611,7 @@ pub(super) fn sheet_eval(
)));
}
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square, from_right: false });
sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square });
Ok(())
}
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
-85
View File
@@ -1,85 +0,0 @@
//! Extrudes from a whole face whose outline is no lines and arcs (#2040), checked through the
//! tool `whole_face_tool` picks.
use std::cell::Cell;
use serde_json::json;
use solidcraft_geom::{Plane, Vec2, Vec3};
use solidcraft_kernel as kernel;
use solidcraft_sketch::{CurveKind, Sketch};
use crate::*;
thread_local! {
static SLABS: Cell<usize> = const { Cell::new(0) };
}
/// A cut took a slab over the whole body as its tool, on this thread.
pub(super) fn slab_taken() {
SLABS.with(|n| n.set(n.get() + 1));
}
/// A closed spline through six points round the origin, on the plane at height `z`.
fn spline_sketch(z: f64) -> FeatureKind {
let mut sk = Sketch::new();
let fit = [(8.0, 0.0), (4.0, 6.0), (-3.0, 7.0), (-7.0, 1.0), (-4.0, -6.0), (3.0, -5.0)];
let mut pts: Vec<usize> = fit.iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
pts.push(pts[0]);
sk.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true }, None).unwrap();
let plane = Plane { origin: Vec3::new(0.0, 0.0, z), ..Plane::XY };
FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: sk }
}
fn extrude(doc: &mut Document, sketch: u64, extent: serde_json::Value, operation: Operation) {
let kind = FeatureKind::Extrude {
thin: None,
sketch: SketchId(sketch),
profiles: ProfileSel::All,
extent: serde_json::from_value(extent).unwrap(),
operation,
targets: vec![],
};
doc.add_feature(kind, None).unwrap();
}
fn volume(model: &Model) -> f64 {
let state = model.state();
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
assert_eq!(state.bodies.len(), 1);
assert!(state.bodies[0].body.validity().is_empty(), "{:?}", state.bodies[0].body.validity());
kernel::measure(&state.bodies[0].body).unwrap().volume
}
/// A prism 10 tall on a closed spline, standing on a 40 x 40 x 5 plate, cut 3 into its whole
/// top face. The walls only touch the prism, so the cut takes a slab over the whole body from 3
/// below the face up: it must take the face's area times 3 and leave the plate alone.
#[test]
fn slab_cut_into_a_spline_prism() {
let mut doc = Document::new("spline prism");
let plate = FeatureKind::Box {
corner: point_expr(Vec3::new(-20.0, -20.0, 0.0)),
length: "40 mm".into(),
width: "40 mm".into(),
height: "5 mm".into(),
operation: Operation::NewBody,
};
doc.add_feature(plate, None).unwrap();
let base = doc.add_feature(spline_sketch(5.0), None).unwrap();
extrude(&mut doc, base, json!({"distance": "10 mm"}), Operation::Join);
let mut model = Model::new();
model.evaluate(&doc);
let joined = volume(&model);
let area = (joined - 40.0 * 40.0 * 5.0) / 10.0;
assert!(area > 100.0, "the prism's area {area}");
let top = doc.add_feature(spline_sketch(15.0), None).unwrap();
extrude(&mut doc, top, json!({"distance": "3 mm", "direction": "negative"}), Operation::Cut);
SLABS.with(|n| n.set(0));
model.evaluate(&doc);
let cut = volume(&model);
assert_eq!(SLABS.with(Cell::get), 1, "the cut took the slab");
assert!((joined - cut - area * 3.0).abs() < 1e-3 * area, "took {}, expected {}", joined - cut, area * 3.0);
let bounds = model.state().bodies[0].mesh().bounds();
assert!((bounds.max.z - 12.0).abs() < 1e-6, "top at {}", bounds.max.z);
assert!(bounds.min.dist(Vec3::new(-20.0, -20.0, 0.0)) < 1e-6, "plate from {:?}", bounds.min);
}
+3 -37
View File
@@ -330,13 +330,8 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
while let Some(&(_, c)) = cs.get(i) {
if c.is_whitespace() {
i += 1;
} else if c.is_ascii_digit() || c == '.' || leading_decimal_comma(&cs, i, depth, out.last()) {
} else if c.is_ascii_digit() || c == '.' {
let st = i;
// A leading decimal comma (`,5` for 0,5, #2966) starts the number as `.5` would.
let leading_comma = c == ',';
if leading_comma {
i += 1;
}
while cs.get(i).is_some_and(|(_, c)| c.is_ascii_digit() || *c == '.') {
i += 1;
}
@@ -345,7 +340,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
// them a comma separates a function's arguments: `max(1,2)`).
let digit = |k: usize| cs.get(k).is_some_and(|(_, c)| c.is_ascii_digit());
let has_point = cs.get(st..i).is_some_and(|ds| ds.iter().any(|(_, c)| *c == '.'));
if depth == 0 && !leading_comma && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
if depth == 0 && !has_point && digit(i.wrapping_sub(1)) && cs.get(i).is_some_and(|(_, c)| *c == ',') && digit(i + 1) {
i += 1;
while digit(i) {
i += 1;
@@ -418,22 +413,6 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
Ok(out)
}
/// Whether the comma at `i` starts a number, as a leading decimal comma (`,5` for 0,5, #2966).
/// It must be outside parentheses (inside them a comma separates arguments, as for any decimal
/// comma), be followed by a digit, and stand where an operand is expected: at the start or right
/// after `+ - * / ^`. A comma after a number, name, `)` or `%` keeps its meaning (`2 ,5` and
/// `1,5,3` stay errors).
fn leading_decimal_comma(cs: &[(usize, char)], i: usize, depth: usize, prev: Option<&(Tok, usize, usize)>) -> bool {
depth == 0
&& cs.get(i).is_some_and(|(_, c)| *c == ',')
&& cs.get(i + 1).is_some_and(|(_, c)| c.is_ascii_digit())
&& match prev {
None => true,
Some((Tok::Op(op), _, _)) => "+-*/^".contains(*op),
Some(_) => false,
}
}
/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`,
/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a
/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the
@@ -1012,24 +991,11 @@ mod tests {
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
assert_eq!(ev("(2,5)", Kind::Unitless).ok(), None, "a comma inside parentheses is not a decimal comma");
// Not between two digits, or a second comma: still an error.
for bad in ["2,", "2 ,5", "2, 5", "1,5,3", "1.5,3", ",5,3", ",5.3", "width ,5", "(1) ,5"] {
for bad in ["2,", ",5", "2 ,5", "2, 5", "1,5,3", "1.5,3"] {
assert!(ev(bad, Kind::Unitless).is_err(), "{bad}");
}
}
#[test]
fn a_leading_decimal_comma_is_a_number() {
// #2966: `,5` is 0,5 where `.5` would be a number: at the start or after an operator.
assert_eq!(ev(",5", Kind::Unitless).unwrap(), 0.5);
assert_eq!(ev(",5 mm", Kind::Length).unwrap(), 0.5);
assert_eq!(ev("-,5", Kind::Unitless).unwrap(), -0.5);
assert_eq!(ev("2*,5", Kind::Unitless).unwrap(), 1.0);
assert_eq!(ev("width - ,25", Kind::Length).unwrap(), 39.75);
// Function arguments are still separated by commas.
assert_eq!(ev("max(1,5)", Kind::Unitless).unwrap(), 5.0);
assert_eq!(ev("max(,5; 2)", Kind::Unitless).ok(), None, "inside parentheses a comma is a separator");
}
#[test]
fn inch_and_foot_marks() {
// #1502: `1"` in a millimetre design is an inch, `2'` two feet, straight or as primes.
+3 -8
View File
@@ -1250,9 +1250,7 @@ mod tests {
file["format"] = Value::from("solidcraft/23");
let opened = read(&file.to_string()).unwrap();
assert_eq!(opened.format, format_string(FORMAT));
let mut kept = plain.features.clone();
kept.iter_mut().for_each(|f| f.kernel_piece_order = true);
assert_eq!(opened.features, kept);
assert_eq!(opened.features, plain.features);
// One that chooses its relief says so in the file, which is stamped 24 and keeps it.
let mut own = Document::new("A");
own.add_feature(flange(Some("round"), Some("6 mm")), None).unwrap();
@@ -1744,7 +1742,7 @@ mod tests {
legacy["format"] = Value::from("solidcraft/6");
let original_table = legacy["configs"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
assert_eq!(upgraded, d, "the identity migration preserves the complete design");
assert_eq!(serde_json::to_value(&upgraded.configs).unwrap(), original_table);
assert_eq!(upgraded.format, format_string(FORMAT));
@@ -1798,12 +1796,9 @@ mod tests {
legacy["format"] = Value::from("solidcraft/7");
let mut features = legacy["features"].clone();
pieces_in_shell_order(&mut features);
for f in features.as_array_mut().into_iter().flatten() {
f["kernel_piece_order"] = Value::Bool(true);
}
let params = legacy["params"].clone();
let upgraded = read(&legacy.to_string()).unwrap();
d.features.iter_mut().for_each(|f| (f.shell_piece_order, f.kernel_piece_order) = (true, true));
d.features.iter_mut().for_each(|f| f.shell_piece_order = true);
assert_eq!(upgraded, d);
let upgraded_json: Value = serde_json::from_str(&upgraded.to_json()).unwrap();
assert_eq!(upgraded_json["features"], features);
@@ -1,5 +0,0 @@
//! An Align can name planes (`from_plane`, `to_plane`: an origin, construction or offset plane, or
//! an explicit one) instead of a point or a face's point and normal, so the bodies follow the plane
//! when it moves. Older designs need no transformation: an Align without them keeps the points and
//! normals it was saved with. The last release refuses a design saved with this (the newer format
//! number).
@@ -1,34 +0,0 @@
//! The pieces of a split face are numbered by place (#3902): `#1`, `#2`, … follow the spread of
//! their centres, not the kernel's face order or the index of the operand face they came from,
//! which can change from one evaluation to the next. Older designs' stored `#k` names mean the
//! old numbering, so this step marks each of their features `kernel_piece_order`, which keeps
//! it for them. Features added later number by place. The last release refuses a design saved
//! with this (the newer format number).
use serde_json::Value;
pub fn step(v: &mut Value) -> crate::Result<()> {
let Some(Value::Array(features)) = v.get_mut("features") else {
return Ok(());
};
for feature in features {
if let Value::Object(o) = feature {
o.insert("kernel_piece_order".into(), Value::Bool(true));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
#[test]
fn older_features_keep_their_piece_numbering() {
let mut v = json!({"features": [{"type": "sketch"}, {"type": "extrude"}]});
super::step(&mut v).unwrap();
assert!(v["features"].as_array().unwrap().iter().all(|f| f["kernel_piece_order"] == true));
let mut empty = json!({});
super::step(&mut empty).unwrap();
assert_eq!(empty, json!({}));
}
}
@@ -1,4 +0,0 @@
//! A sheet's perforation field can run its grid from the flat's right edge (`from_right`): the
//! field of a mirrored sheet copy, whose holes are the mirror image of its source's. Older designs
//! need no transformation: a field without `from_right` runs from the left as before. The last
//! release refuses a design saved with this (the newer format number).
+16 -68
View File
@@ -17,7 +17,6 @@
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, OnceLock};
use solidcraft_geom::Vec3;
use solidcraft_kernel::history::{FaceOrigin, FaceProv, History};
use crate::ModelBody;
@@ -45,14 +44,13 @@ pub(crate) struct FaceInfo {
impl Names {
/// Names worked out from geometry: the same names, none of them verified, pieces told apart
/// by the faces around them (`adjacent`: pairs of faces sharing an edge), then numbered by
/// place (`centres`, see [`qualify_at`]).
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
/// by the faces around them (`adjacent`: pairs of faces sharing an edge).
pub(crate) fn unverified(names: Vec<String>, adjacent: &[(usize, usize)]) -> Names {
let named = names
.into_iter()
.map(|name| Named { name: crate::naming::strip_piece(&name).to_string(), aliases: Vec::new(), verified: false, origins: None })
.collect();
qualify_at(named, adjacent, centres)
qualify(named, adjacent)
}
/// Names given as they are (a moved copy keeps its source's), taken as exact.
@@ -97,8 +95,7 @@ pub(crate) fn derive(b: &ModelBody, o: &NamingOrigin, lineage: &Lineage) -> Opti
}
}
let named: Vec<Named> = named.into_iter().collect::<Option<_>>()?;
let centres = || crate::naming::face_centres(b);
Some(qualify_at(named, &adjacency(h), by_place(&o.feature, &centres)))
Some(qualify(named, &adjacency(h)))
}
/// Pairs of faces that share an edge, from a history.
@@ -143,8 +140,7 @@ impl<'a> OperandNames<'a> {
.get_or_init(|| {
Arc::new(derive(body, o, &stage.lineage).unwrap_or_else(|| {
let names = crate::naming::geometric_names(body, o, body.body.face_count());
let centres = || crate::naming::face_centres(body);
Names::unverified(names, &crate::naming::mesh_adjacency(body), by_place(&o.feature, &centres))
Names::unverified(names, &crate::naming::mesh_adjacency(body))
}))
})
.clone();
@@ -270,32 +266,7 @@ pub(crate) fn descends(name: &str, stem: &str) -> bool {
/// Tell pieces apart: faces sharing a name are named by the faces around them that the others
/// lack (`adjacent`: pairs of faces sharing an edge).
#[cfg(test)]
fn qualify(named: Vec<Named>, adjacent: &[(usize, usize)]) -> Names {
qualify_at(named, adjacent, None)
}
/// How the pieces `f` splits a face into are numbered: by place (`centres`), else, for a feature
/// saved before that (`Feature::kernel_piece_order`), as the build that saved it numbered them.
pub(crate) fn by_place<'a>(f: &crate::Feature, centres: &'a dyn Fn() -> Vec<Vec3>) -> Option<&'a dyn Fn() -> Vec<Vec3>> {
(!f.kernel_piece_order).then_some(centres)
}
/// [`qualify`], with `centres` giving each face's centre (worked out only when pieces tie): pieces
/// that nothing else tells apart are numbered along their spread, not in the kernel's order,
/// which can differ from one evaluation to the next (#3902). Without `centres`, in the kernel's
/// order and by the faces they came from, as builds before that numbered them.
fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&dyn Fn() -> Vec<Vec3>>) -> Names {
let by_place = centres.is_some();
let worked_out = std::cell::OnceCell::new();
// Tied pieces in the order of their spread; as they are when there are no centres.
let in_order = |tied: Vec<usize>| -> Vec<usize> {
let at: &Vec<Vec3> = worked_out.get_or_init(|| centres.map(|c| c()).unwrap_or_default());
if at.len() != named.len() {
return tied;
}
crate::naming::along_spread(tied.into_iter().filter_map(|i| Some((i, *at.get(i)?))).collect())
};
let n = named.len();
let mut bounds: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
for &(a, b) in adjacent {
@@ -349,20 +320,8 @@ fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&d
let mut tied = tied;
let origins: Vec<Option<&Vec<FaceOrigin>>> = tied.iter().map(|i| named.get(*i).and_then(|x| x.origins.as_ref())).collect();
let distinct = origins.iter().all(Option::is_some) && origins.iter().collect::<BTreeSet<_>>().len() == origins.len();
if distinct && !by_place {
if distinct {
tied.sort_by_key(|i| named.get(*i).and_then(|x| x.origins.clone()));
} else if distinct {
// By what they came from, but not by where it stood in its operand's face order:
// an operand that came out of a boolean can list its faces in another order on
// the next evaluation (#3902). Pieces of faces of one kind are numbered by place.
let mut by_kind: BTreeMap<Vec<FaceOrigin>, Vec<usize>> = BTreeMap::new();
for i in tied {
let kind = named.get(i).and_then(|x| x.origins.as_deref()).unwrap_or_default().iter().map(|o| without_index(*o)).collect();
by_kind.entry(kind).or_default().push(i);
}
tied = by_kind.into_values().flat_map(&in_order).collect();
} else {
tied = in_order(tied);
}
for (k, i) in tied.iter().enumerate() {
if let Some(slot) = names.get_mut(*i) {
@@ -374,17 +333,17 @@ fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&d
}
}
}
// A piece's new name can meet a face that already had it: numbered too (along their spread),
// never shared.
let mut seen: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (i, name) in names.iter().enumerate() {
seen.entry(name.clone()).or_default().push(i);
// A piece's new name can meet a face that already had it: numbered too, never shared.
let mut seen: BTreeMap<String, usize> = BTreeMap::new();
for name in &names {
*seen.entry(name.clone()).or_default() += 1;
}
for (name, same) in seen.into_iter().filter(|(_, same)| same.len() > 1) {
for (k, i) in in_order(same).into_iter().enumerate() {
if let Some(slot) = names.get_mut(i) {
*slot = format!("{name}#{}", k + 1);
}
let mut counter: BTreeMap<String, usize> = BTreeMap::new();
for (i, name) in names.iter_mut().enumerate() {
if seen.get(name.as_str()).is_some_and(|c| *c > 1) {
let k = counter.entry(name.clone()).or_default();
*k += 1;
*name = format!("{name}#{k}");
if let Some(v) = verified.get_mut(i) {
*v = false;
}
@@ -394,17 +353,6 @@ fn qualify_at(named: Vec<Named>, adjacent: &[(usize, usize)], centres: Option<&d
Names { faces: Arc::new(names), info }
}
/// `origin` with the face or edge it names left out (its kind and operand kept).
fn without_index(origin: FaceOrigin) -> FaceOrigin {
match origin {
FaceOrigin::Face { operand, .. } => FaceOrigin::Face { operand, face: 0 },
FaceOrigin::Edge { operand, .. } => FaceOrigin::Edge { operand, edge: 0 },
FaceOrigin::Offset { operand, .. } => FaceOrigin::Offset { operand, face: 0 },
// A profile's segments come in the sketch's order.
FaceOrigin::Profile { .. } | FaceOrigin::Cap { .. } => origin,
}
}
/// How a stored face name matches a current face.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Match {
+1 -2
View File
@@ -57,12 +57,11 @@ pub use assembly::{
rigid,
};
pub use document::*;
pub use eval::KeptResults;
pub use eval::{
EdgeRef, FeatureResult, Model, ModelBody, ModelState, PlacedBodies, RevolveExtent, SavedModel, SolvedSketch, ThreadInfo, curve_pick_error,
edge_names_for, edge_refs, face_point_names, face_point_state, hole_direction_at, import_body_names, metric_thread_sizes, object_height,
parse_metric_thread, pattern_matrices, revolve_extent, revolve_extent_with_convention, select_profiles, sketch_hole_direction, sketch_line_plane,
state_in_frame, state_in_frame_at, suggested_thread, tangent_face_chain, through_all_distance, world_state, world_state_reusing,
state_in_frame, state_in_frame_at, suggested_thread, through_all_distance, world_state, world_state_reusing,
};
pub use expr::Expr;
pub use names::{BodyName, FeatureName, SketchId};
+36 -46
View File
@@ -241,11 +241,13 @@ pub(crate) fn names(b: &ModelBody) -> Arc<Names> {
.get_or_init(|| {
// Exact history needs no mesh; geometry does (its face count is the mesh's).
let names = match b.names.origin.as_ref() {
Some(o) if o.depth < MAX_DEPTH => b.names.lineage.as_ref().and_then(|l| crate::history_names::derive(b, o, l)).unwrap_or_else(|| {
let centres = || face_centres(b);
Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b), crate::history_names::by_place(&o.feature, &centres))
}),
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[], None),
Some(o) if o.depth < MAX_DEPTH => b
.names
.lineage
.as_ref()
.and_then(|l| crate::history_names::derive(b, o, l))
.unwrap_or_else(|| Names::unverified(geometric_names(b, o, face_count(b)), &mesh_adjacency(b))),
_ => Names::unverified((0..face_count(b)).map(|k| format!("{}:face:{k}", b.name)).collect(), &[]),
};
Arc::new(names)
})
@@ -517,8 +519,35 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
let ps = pts.get(j).cloned().unwrap_or_default();
ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)
};
let order = along_spread(same.iter().map(|j| (*j, centre(*j))).collect());
for (k, j) in order.iter().enumerate() {
let cs: Vec<(usize, Vec3)> = same.iter().map(|j| (*j, centre(*j))).collect();
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
let key = |c: Vec3| {
if ext.x >= ext.y && ext.x >= ext.z {
(c.x, c.y, c.z)
} else if ext.y >= ext.z {
(c.y, c.z, c.x)
} else {
(c.z, c.x, c.y)
}
};
let mut order = cs.clone();
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
// keeps its number across runs (solidcraft#569). total_cmp keeps a NaN deterministic.
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
let bucket = |c: Vec3| {
let k = key(c);
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
};
order.sort_by_key(|a| bucket(a.1));
for (k, (j, _)) in order.iter().enumerate() {
if let Some(slot) = names.get_mut(*j) {
*slot = format!("{n}{mark}{}", k + 1);
}
@@ -526,45 +555,6 @@ fn number_pieces(names: &mut [String], pts: &[Vec<Vec3>], mark: char) {
}
}
/// The items of `cs` (index, centre) ordered along the longest axis of their centres' spread,
/// then the next: how the pieces of one face or edge are numbered, the same on every run
/// whatever order the kernel gives them in.
pub(crate) fn along_spread(cs: Vec<(usize, Vec3)>) -> Vec<usize> {
let ext = cs.iter().map(|(_, c)| *c).collect::<Aabb3>().size();
let key = |c: Vec3| {
if ext.x >= ext.y && ext.x >= ext.z {
(c.x, c.y, c.z)
} else if ext.y >= ext.z {
(c.y, c.z, c.x)
} else {
(c.z, c.x, c.y)
}
};
let mut order = cs;
// Pieces lying almost level along the sort axis differ only in the last bits of their centre:
// the centre is the mean of a tessellated boundary, and that depends on where the loop's
// polyline starts, which can differ between processes. Comparing the keys with a tolerance
// makes those pieces equal, so the stable sort keeps the order they arrived in and a name
// keeps its number across runs (solidcraft#569).
let tol = ext.x.max(ext.y).max(ext.z).max(1.0) * 1e-9;
let bucket = |c: Vec3| {
let k = key(c);
// Rounding onto the grid, rather than comparing with a tolerance, is what keeps this a
// total order: `a` and `b` in one bucket and `b` and `c` in the next must not leave `a`
// and `c` in different ones, or the sort has no order to work with. Since Rust 1.81
// `sort_by` may panic on a comparator that is not one. Two pieces can still flip when
// they straddle a bucket edge; that is the price of a key that ignores their low bits.
((k.0 / tol).round() as i64, (k.1 / tol).round() as i64, (k.2 / tol).round() as i64)
};
order.sort_by_key(|a| bucket(a.1));
order.into_iter().map(|(j, _)| j).collect()
}
/// Each face's centre: the mean of the sampled centres of its triangles.
pub(crate) fn face_centres(b: &ModelBody) -> Vec<Vec3> {
samples(&b.mesh(), face_count(b)).into_iter().map(|ps| ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64)).collect()
}
/// The name of a face new to `f`.
fn role_name(f: &Feature, before: &ModelState, ps: &[Vec3], ns: &[Vec3], fi: usize, fid: u64) -> String {
let c = ps.iter().fold(Vec3::ZERO, |a, p| a + *p) * (1.0 / ps.len().max(1) as f64);
+3 -105
View File
@@ -541,10 +541,6 @@ pub struct SheetPerforation {
pub pitch: f64,
pub margin: f64,
pub square: bool,
/// The grid runs from the flat's right edge instead of its left: the field of a mirrored
/// copy, whose holes are the mirror image of its source's.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub from_right: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -1996,11 +1992,8 @@ impl SheetBody {
if y > hi.y - clear + 1e-9 {
break;
}
let shift = if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
// Steps across the row from its first hole; a mirrored field counts from the right.
let mut step = 0.0;
while lo.x + clear + shift + step <= hi.x - clear + 1e-9 {
let x = if p.from_right { hi.x - clear - shift - step } else { lo.x + clear + shift + step };
let mut x = lo.x + clear + if !p.square && row % 2 == 1 { p.pitch / 2.0 } else { 0.0 };
while x <= hi.x - clear + 1e-9 {
let c = Vec2::new(x, y);
let (i, j) = idx(x, y);
let near = grid.get(j * nx + i).map(Vec::as_slice).unwrap_or(&[]);
@@ -2010,7 +2003,7 @@ impl SheetBody {
{
out.push(c);
}
step += p.pitch;
x += p.pitch;
}
row += 1;
}
@@ -4362,101 +4355,6 @@ impl Letters {
}
}
/// A flat point mirrored across the flat frame's y axis (x → −x).
fn flip_x(p: Vec2) -> Vec2 {
Vec2::new(-p.x, p.y)
}
/// A loop mirrored across the flat frame's y axis, exactly (arcs stay arcs, free-form curves
/// follow their control points), and run backwards so it keeps its sense: an outer loop stays
/// counter-clockwise, a hole clockwise.
fn flip_x_loop(l: &Loop2) -> Loop2 {
let segs = l
.segs
.iter()
.map(|s| match *s {
Seg2::Arc { center, radius, start, sweep } => {
Seg2::Arc { center: flip_x(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }
}
other => map_controls(&other, flip_x),
})
.collect();
Loop2 { segs }.reversed()
}
fn flip_x_region(r: &Region2) -> Region2 {
Region2 { outer: flip_x_loop(&r.outer), holes: r.holes.iter().map(flip_x_loop).collect() }
}
impl SheetBody {
/// The sheet model of a copy of this body moved by `m` and named `body`: a rigid motion
/// moves its frame; a mirror moves its frame too and mirrors everything in the flat, so the
/// copy is the opposite hand, with the same thickness side, bends, K-factor and flat
/// length. `None` when `m` is not a rigid motion or a mirror (a scaled copy is no sheet).
pub fn copied(&self, body: &str, m: &Mat) -> Option<SheetBody> {
let axis = |i: usize| m.get(i).map(|c| Vec3::new(c[0], c[1], c[2]));
let (x, y, z) = (axis(0)?, axis(1)?, axis(2)?);
let unit = |v: Vec3| (v.dot(v) - 1.0).abs() < 1e-6;
let isometry = m.iter().flatten().all(|v| v.is_finite())
&& unit(x)
&& unit(y)
&& unit(z)
&& x.dot(y).abs() < 1e-6
&& x.dot(z).abs() < 1e-6
&& y.dot(z).abs() < 1e-6;
if !isometry {
return None;
}
let (origin, fx, fy) = (apply_point(m, self.frame.origin), apply_vector(m, self.frame.x), apply_vector(m, self.frame.y));
let mut out = self.clone();
out.body = body.to_string();
if x.cross(y).dot(z) > 0.0 {
out.frame = Plane::new(origin, fx, fy)?;
} else {
// The mirror of flat point (u, v, w) is the point (−u, v, w) of the frame with its x
// axis turned round: that frame's normal is the mirrored normal, so the material
// stays on the same side of the mirrored base face.
out.frame = Plane::new(origin, fx * -1.0, fy)?;
out.flip_flat();
}
Some(out)
}
/// Everything in flat coordinates mirrored across the flat's y axis (x → −x).
fn flip_flat(&mut self) {
self.base = flip_x_region(&self.base);
for f in &mut self.flanges {
// The bend line runs the other way, from its far end, so the flange still lies on
// the same side of it (`n` turned as `d` is): its ends swap.
f.p = flip_x(f.p + f.d * f.len);
f.d = Vec2::new(f.d.x, -f.d.y);
f.n = flip_x(f.n);
f.ext = (f.ext.1, f.ext.0);
f.slope = (f.slope.1, f.slope.0);
f.tear = (f.tear.1, f.tear.0);
if let Some(shape) = &mut f.shape {
*shape = shape.iter().rev().map(|p| flip_x(*p)).collect();
}
if let Some(exact) = &mut f.exact {
*exact = flip_x_loop(exact);
}
}
for r in self.holes.iter_mut().chain(self.labels.iter_mut()).chain(self.pockets.iter_mut().map(|p| &mut p.region)) {
*r = flip_x_region(r);
}
for b in &mut self.bores {
b.base.x = -b.base.x;
b.axis.x = -b.axis.x;
}
for p in &mut self.perforations {
p.from_right = !p.from_right;
}
for (_, a, b) in &mut self.base_curves {
(*a, *b) = (flip_x(*b), flip_x(*a));
}
}
}
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
let (a, b) = (planar_region(a)?, planar_region(b)?);
-22
View File
@@ -103,28 +103,6 @@ fn set_back_reports_restored_features() {
assert_eq!((m.last_recomputed, m.last_restored), (0, 0));
}
/// #1989: what an evaluation ahead on a copy of the model computed is kept for the run that
/// follows on the model itself, which finds it there even once the evaluation cache has dropped
/// it (the cache keeps only a few states of a design with large bodies). Copies keep nothing.
#[test]
fn results_kept_ahead_outlive_the_cache() {
let (mut doc, _) = plate_doc();
let mut m = Model::new();
m.evaluate(&doc);
doc.set_param("width", "50 mm", None, None).unwrap();
let kept = std::sync::Arc::new(KeptResults::default());
let mut ahead = m.clone();
ahead.keep(Some(kept.clone()));
assert!(ahead.clone().kept().is_none(), "a copy keeps nothing");
ahead.evaluate(&doc);
assert_eq!((ahead.last_recomputed, kept.len()), (2, 2));
*m.cache.lock().unwrap() = Default::default();
m.keep(Some(kept));
m.evaluate(&doc);
assert_eq!((m.last_recomputed, m.last_restored), (0, 2), "nothing computed again");
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
}
#[test]
fn box_fillet_cut_timeline() {
let (mut doc, _) = plate_doc();
@@ -1150,76 +1150,3 @@ fn joined_loft_frames_reference_mesh_covers_each_face_once() {
}
}
}
/// The block of #2040 with `extra` run after it.
fn rounded_block_and(extra: Value) -> Session {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
script["commands"].as_array_mut().unwrap().push(extra);
replay(&script.to_string())
}
/// #2040: the slanted face of a block whose ends fillets round, extruded as a join. Its outline's
/// quarter-ellipses come into the face's sketch as splines a hair off the body's edges, and the
/// join failed with "boolean Union: no result"; it now sweeps the body's own face. It adds the
/// face's area times its height: a 10 x 12 face less four corners of (1 - pi/4) r * r*sqrt(2).
#[test]
fn extrude_join_from_a_face_rounded_by_fillets() {
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
let r = 3.8_f64;
let area = 10.0 * 12.0 - 4.0 * (1.0 - std::f64::consts::FRAC_PI_4) * r * r * 2f64.sqrt();
let joined = rounded_block_and(json!({"command": "solid.extrude", "params": {"face": at, "distance": 5, "operation": "join"}}));
let bodies = &joined.model.state().bodies;
assert_eq!(bodies.len(), 1, "the join makes no second body");
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let v = volume(&joined, "Body1");
assert!((v - (before + area * 5.0)).abs() < 0.05, "join {v} expected {}", before + area * 5.0);
}
/// #2040: a cut 3 mm into the slanted face of the block rounded along its top only (its bottom
/// edge left sharp). The walls from the bottom edge go into the body and the others only touch
/// it, along edges with curved faces beside them: the cut failed with "boolean Cut: no result".
/// In the face's plane the body's section at depth t is the block's section (10 wide, h high, its
/// top corners rounded) moved down the face by t: the cut takes
/// √2 ∫₀³ (10 (h − t/√2) − 2 (1 − π/4) r²) dt.
#[test]
fn extrude_cut_into_a_face_beside_rounds() {
let at = json!([16.191269086108846, -6.702792508916929, 4.676550460347439]);
let top_only = |extra: Option<Value>| {
let mut script: Value = serde_json::from_str(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")).unwrap();
let commands = script["commands"].as_array_mut().unwrap();
let fillet = commands.last_mut().unwrap();
fillet["params"]["edges"] = json!([[10, -10, 8.485281374238571], [10, 0, 8.485281374238571]]);
commands.extend(extra);
replay(&script.to_string())
};
let before = volume(&top_only(None), "Body1");
let cut = top_only(Some(json!({"command": "solid.extrude", "params": {"face": at, "distance": 3, "direction": "negative", "operation": "cut"}})));
let bodies = &cut.model.state().bodies;
assert_eq!(bodies.len(), 1);
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let (h, r, d, w) = (8.485281374238571_f64, 3.8_f64, 3.0_f64, 10.0_f64);
let corner = (1.0 - std::f64::consts::FRAC_PI_4) * r * r;
let taken = 2f64.sqrt() * w * h * d - w * d * d / 2.0 - 2.0 * 2f64.sqrt() * corner * d;
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
/// #2040: a cut 2 mm into the top face of the block rounded at its ends, the strip 10 − 2r wide
/// between the rounds. Below the face the rounds stand out past its outline, so a slab over the
/// whole body would take them too: the cut takes only what lies over the face, at height z from
/// x = 0 to the slanted face at x = x0 + z.
#[test]
fn extrude_cut_into_a_rounded_block_top() {
let (h, r, x0, d) = (8.485281374238571_f64, 3.8_f64, 11.514718625761429_f64, 2.0_f64);
let before = volume(&replay(include_str!("../../../examples/regressions/extrude_face_rounded_by_fillets.json")), "Body1");
let cut = rounded_block_and(
json!({"command": "solid.extrude", "params": {"face": [5, -5, h], "distance": d, "direction": "negative", "operation": "cut"}}),
);
let bodies = &cut.model.state().bodies;
assert_eq!(bodies.len(), 1);
assert!(bodies[0].body.validity().is_empty(), "{:?}", bodies[0].body.validity());
let taken = (10.0 - 2.0 * r) * (x0 * d + (h * h - (h - d) * (h - d)) / 2.0);
let v = before - volume(&cut, "Body1");
assert!((v - taken).abs() < 0.05, "took {v}, expected {taken}");
}
+4 -30
View File
@@ -559,24 +559,18 @@ fn rename_body(s: &mut Session, p: &Value) -> Result<Value> {
let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty());
f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first)
};
// Bodies later features name after this one (`Top (1)`, a split's other piece) keep their
// names too: they would otherwise follow it, and break what names them (#3510).
let derived = format!("{old} (");
let at = s.doc.features.iter().position(|f| f.id == fid);
let counts = at.and_then(|at| s.doc.features.get(at)).is_some_and(|f| counted(f, k));
let later: Vec<(u64, Vec<String>)> = match at {
Some(at) => s
let later: Vec<(u64, Vec<String>)> = match s.doc.features.iter().position(|f| f.id == fid) {
Some(at) if s.doc.features.get(at).is_some_and(|f| counted(f, k)) => s
.doc
.features
.iter()
.skip(at + 1)
.filter_map(|f| {
let made: Vec<String> = s.model.result(f.id)?.state.bodies.iter().filter(|x| x.feature == f.id).map(|x| x.name.clone()).collect();
let keep = (counts && (0..made.len()).any(|i| counted(f, i))) || made.iter().any(|n| n.starts_with(&derived));
keep.then_some((f.id, made))
(0..made.len()).any(|i| counted(f, i)).then_some((f.id, made))
})
.collect(),
None => Vec::new(),
_ => Vec::new(),
};
let d = s.doc_mut();
for (id, made) in &later {
@@ -777,26 +771,6 @@ mod tests {
assert_eq!(names(&s)[1], b);
}
/// #3510: each piece of a split is renamed on its own. Renaming the split body leaves the
/// other piece, and the features that use it, as they were.
#[test]
fn split_pieces_are_renamed_on_their_own() {
let ok = |s: &Session| s.model.results.iter().all(|r| r.error.is_none());
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, -5], "body_name": "Top"}));
run(&mut s, "solid.split_body", json!({"body": "Top", "plane": "XY"}));
assert_eq!(names(&s), vec!["Top", "Top (1)"]);
run(&mut s, "solid.move", json!({"bodies": ["Top (1)"], "translate": [0, 0, -5]}));
run(&mut s, "body.rename", json!({"body": "Top", "name": "Upper"}));
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
assert!(ok(&s), "the move still finds the other piece");
run(&mut s, "body.rename", json!({"body": "Top (1)", "name": "Lower"}));
assert_eq!(names(&s), vec!["Upper", "Lower"]);
assert!(ok(&s), "the move follows the renamed piece");
run(&mut s, "edit.undo", json!({}));
assert_eq!(names(&s), vec!["Upper", "Top (1)"]);
}
/// #1108: a primitive's `targets` are its participants, kept beside its kind; a rename
/// follows them, as it follows the names inside the kind (a combine's target and tools).
#[test]
@@ -111,9 +111,8 @@ fn legacy_v9_imported_emboss_anchor_and_ordinary_face_paint_replay_without_renam
let mut legacy: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
legacy["format"] = json!("solidcraft/9");
let reopened = Session::new(solidcraft_doc::Document::from_json(&legacy.to_string()).unwrap());
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order,
// and numbering the pieces of its faces as that build did (#3902).
let anchored = solidcraft_doc::Feature { shell_piece_order: true, kernel_piece_order: true, ..anchored };
// Saved before format 14, it keeps naming the pieces it makes in the kernel's shell order.
let anchored = solidcraft_doc::Feature { shell_piece_order: true, ..anchored };
assert_eq!(reopened.doc.feature(anchored.id).unwrap(), &anchored);
// Names saved before format 14 keep naming the face they named, marked so they are found as
// the build that saved them found them.
+4 -20
View File
@@ -63,30 +63,14 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
/// The point is in the frame of the command's other picks: the component's own when
/// they were mapped into it, else the world's.
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
match face_ref_form(v).map_err(|why| bad(cmd, why))? {
FaceRefForm::Point(p) => Ok(p),
FaceRefForm::Name(name) => {
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why))
}
}
}
/// A face reference as given, before it is looked up: see [`face_ref`].
pub(crate) enum FaceRefForm<'a> {
Point(Vec3),
Name(&'a str),
}
/// What [`face_ref`] reads `v` as, without the design: a point, or a name of 1…512 bytes.
pub(crate) fn face_ref_form(v: &Value) -> std::result::Result<FaceRefForm<'_>, &'static str> {
if let Some(name) = v.as_str() {
if name.is_empty() || name.len() > 512 {
return Err("a face name is 1…512 characters");
return Err(bad(cmd, "a face name is 1…512 characters"));
}
return Ok(FaceRefForm::Name(name));
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
}
vec3(v).or_else(|| v.get("point").and_then(vec3)).map(FaceRefForm::Point).ok_or("a face is a point [x, y, z] on it, or its name from model.faces")
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
}
/// The body face under a point: (mesh, face index, distance, body).
+64 -125
View File
@@ -11,8 +11,7 @@ use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::schema::features::{BoxParams, ExtrudeParams, FeatureResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::schema::{BoxParams, ExtrudeParams, FeatureResult, TypedContract, output_schema_for};
use crate::{EngineError, Result, Session};
const BOX_CONTRACT: TypedContract<BoxParams> = TypedContract::new();
@@ -68,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"),
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("solid.pipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
@@ -92,7 +91,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body; a shaft thicker than the thread is first turned down to its major diameter over the threaded length"),
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face); modeled?: bool (default false). Cosmetic: the model is unchanged, model.threads lists it. Modeled: cuts the thread profile into the body"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
@@ -205,16 +204,13 @@ fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'stat
// Match the evaluator's implicit participants: a grouped cut from an empty
// assembly component considers every body; otherwise keep the active scope.
// Explicit participants override either default.
// A component with no body of its own works on those of the components inside it (#3223).
let parts = string_list(p, "participants");
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
let bodies: Vec<_> = if !parts.is_empty() {
st.bodies.iter().filter(|b| parts.contains(&b.name)).collect()
} else if implicit_all {
st.bodies.iter().collect()
} else {
active_scope(s, &st)
};
let bodies: Vec<_> = st
.bodies
.iter()
.filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
.collect();
if bodies.is_empty() {
return Some("new");
}
@@ -245,7 +241,11 @@ pub(super) fn operation(p: &Value, cmd: &str) -> Result<Operation> {
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
if let Some(v) = p.get("sketch") {
let key = sketch_key(Some(v)).ok_or_else(|| bad(cmd, "`sketch` must be an id or name"))?;
let key = match v {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
return match f.kind {
FeatureKind::Sketch { .. } => Ok(f.id),
@@ -497,12 +497,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
// A cut or intersect made where the active component has no body of its own (a sketch in
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
// takes part in every body instead, which it changes only where it meets them. An extrude's
// own operation counts too: an automatic one that came out as a cut (#3223).
// takes part in every body instead, which it changes only where it meets them.
let participants = if participants.is_empty()
&& !primitive
&& (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|| matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
&& {
let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
@@ -656,40 +655,14 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
/// it is one the active component works on ([`active_scope`]).
/// it is a body of the active component.
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
let FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch)?.kind else {
return None;
};
let st = s.model.state();
active_scope(s, &st).into_iter().find(|b| b.name == anchor.target.body).map(|b| b.name.clone())
}
/// The bodies a feature of the active component works on when it names none: the component's
/// own. A component with no body of its own (the root of an opened STEP assembly, whose parts
/// are components inside it) works on the bodies of the components inside it whose geometry is
/// in its own frame, as an import places it: a profile sketched on such a part's face meets the
/// part where it is shown (#3223).
fn active_scope<'a>(s: &Session, st: &'a solidcraft_doc::ModelState) -> Vec<&'a solidcraft_doc::ModelBody> {
let active = s.active_component;
let own: Vec<_> = st.bodies.iter().filter(|b| s.doc.model_body_component(b) == active).collect();
if !own.is_empty() {
return own;
}
let Some(into_active) = solidcraft_doc::mat_inverse(&s.doc.component_transform(active)) else { return own };
st.bodies
.iter()
.filter(|b| {
let comp = s.doc.model_body_component(b);
if !s.doc.component_within(comp, active) {
return false;
}
let frame = s.doc.component_transform(comp);
let world = s.doc.model_body_offset(b).map_or(frame, |offset| solidcraft_doc::mat_mul(&frame, offset));
let rel = solidcraft_doc::mat_mul(&into_active, &world);
rel.iter().flatten().zip(solidcraft_doc::IDENTITY.iter().flatten()).all(|(a, b)| (a - b).abs() < 1e-9)
})
.collect()
let body = st.body(&anchor.target.body)?;
(s.doc.model_body_component(body) == s.active_component).then(|| body.name.clone())
}
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
@@ -997,64 +970,45 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
let mut out = Vec::new();
for e in a {
let pt = match edge_ref_form(e).map_err(|why| bad(cmd, why))? {
EdgeRefForm::Point(pt) => pt,
EdgeRefForm::Index { body, index } => {
let idx = index as usize;
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
continue;
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
})
.collect()
});
let mut nearest = f64::INFINITY;
let on_edge = bodies.iter().any(|(size, edges)| {
let d = edges
.iter()
.flat_map(|e| e.points.windows(2))
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
d <= size * 0.05 + 1e-3
});
if !on_edge && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
}
};
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
})
.collect()
});
let mut nearest = f64::INFINITY;
let on_edge = bodies.iter().any(|(size, edges)| {
let d = edges
.iter()
.flat_map(|e| e.points.windows(2))
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
d <= size * 0.05 + 1e-3
});
if !on_edge && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
out.push(pt);
continue;
}
out.push(pt);
let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?;
let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize;
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
}
Ok(out)
}
/// An edge reference as given, before it is looked up: see [`edge_points`].
pub(crate) enum EdgeRefForm<'a> {
Point(Vec3),
Index { body: &'a str, index: u64 },
}
/// What [`edge_points`] reads one edge as, without the design: a point on it (`[x, y, z]` or
/// `{point}`), else `{body, index}`.
pub(crate) fn edge_ref_form(e: &Value) -> std::result::Result<EdgeRefForm<'_>, &'static str> {
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
return Ok(EdgeRefForm::Point(pt));
}
let body = e.get("body").and_then(Value::as_str).ok_or("an edge is a point [x,y,z], {point} or {body, index}")?;
let index = e.get("index").and_then(Value::as_u64).ok_or("edge `index` must be a number")?;
Ok(EdgeRefForm::Index { body, index })
}
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1853,25 +1807,15 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
// that body: the pick is already in that body's frame, and the component's own bodies alone
// would leave nothing to cut.
let mut p = p.clone();
let targets = string_list(&p, "targets");
if p.get("participants").is_none() {
// The bodies to drill, as `targets` (like an extrude's): its participants.
if !targets.is_empty() {
p["participants"] = json!(targets);
} else if let Some(body) = points.as_ref().filter(|(sp, _, _)| !sp.single).and_then(|(sp, _, _)| sketch_face_body(s, sp.sketch.0)) {
// Holes at the points of a sketch on a body's face drill that body (#2882): a body
// further along, past the face, doesn't take them. Stored, so recomputes and
// reopening keep the same scope.
p["participants"] = json!([body]);
} else if points.is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
// A hole picked on a body of another component (a moved one, shown where it is
// placed) cuts that body: the pick is already in that body's frame, and the
// component's own bodies alone would leave nothing to cut.
p["participants"] = json!([body]);
}
if points.is_none()
&& p.get("participants").is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
p["participants"] = json!([body]);
}
add_feature(
s,
@@ -1976,22 +1920,17 @@ fn draft(s: &mut Session, p: &Value) -> Result<Value> {
}
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
let key = sketch_key(v).ok_or_else(|| bad(cmd, format!("`{what}` must be a sketch id or name")))?;
let key = match v {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, format!("`{what}` must be a sketch id or name"))),
};
match s.doc.find_feature(&key) {
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => Ok(f.id),
_ => Err(bad(cmd, format!("no sketch `{key}`"))),
}
}
/// The key [`sketch_id`] and [`feature_sketch`] look a sketch up by: an id (a number) or a name.
pub(crate) fn sketch_key(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::Number(n)) => Some(n.to_string()),
Some(Value::String(x)) => Some(x.clone()),
_ => None,
}
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sweep";
let sketch = feature_sketch(s, p, cmd)?;
+5 -30
View File
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("align")
.params("bodies: [names]; from: [x,y,z] | \"origin\" | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z] | from_plane: XY|XZ|YZ|construction or offset plane name or id|{origin, normal}; to: (the same) | to_face: [x,y,z] | to_plane: (a plane as from_plane; the point or face goes onto it along its normal, and the plane may move later: the bodies follow); faces and planes also turn to meet; flip?: bool"),
.params("bodies: [names]; from: [x,y,z] | {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]} | from_face: [x,y,z]; to: (the same) | to_face: [x,y,z] (faces also turn to meet); flip?: bool"),
CommandSpec::new("solid.remove", "Remove", remove).at("SOLID", "MODIFY").icon("delete").params("bodies: [names] (removed from here on in the timeline)"),
];
@@ -385,46 +385,23 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, format!("no body `{b}`")));
}
}
// A side is a plane (`from_plane`/`to_plane`: XY|XZ|YZ, a construction or offset plane, or
// {origin, normal}), kept as a plane so the bodies follow it when it moves; or a point and, for
// a face, its normal.
let plane = |s: &Session, key: &str| -> Result<Option<solidcraft_doc::PlaneRef>> {
p.get(key).map(|_| super::features::plane_param(s, p.get(key), cmd)).transpose()
};
let (from_plane, to_plane) = (plane(s, "from_plane")?, plane(s, "to_plane")?);
let pick = |s: &Session, pk: &str, fk: &str, plane: &Option<solidcraft_doc::PlaneRef>| -> Result<(Vec3, Option<Vec3>)> {
if plane.is_some() {
if p.get(pk).is_some() || p.get(fk).is_some() {
return Err(bad(cmd, format!("give `{pk}_plane` or `{pk}`/`{fk}`, not both")));
}
// Worked out from the plane when the feature is evaluated.
return Ok((Vec3::ZERO, None));
}
let pick = |s: &Session, pk: &str, fk: &str| -> Result<(Vec3, Option<Vec3>)> {
if let Some(fp) = p.get(fk).and_then(vec3) {
let n = super::face::face_normal(s, fp).ok_or_else(|| bad(cmd, format!("`{fk}` must be on a planar face")))?;
return Ok((fp, Some(n)));
}
// The origin point.
if p.get(pk).and_then(Value::as_str).is_some_and(|o| o.eq_ignore_ascii_case("origin")) {
return Ok((Vec3::ZERO, None));
}
// A snap point: {snap: vertex|edge_mid|face_center|circle_center, at: [x,y,z]}.
if let Some(o) = p.get(pk).filter(|v| v.is_object()) {
let at = o.get("at").and_then(vec3).ok_or_else(|| bad(cmd, format!("`{pk}.at` must be [x, y, z]")))?;
let kind = o.get("snap").and_then(Value::as_str).unwrap_or("vertex");
return snap_point(s, kind, at).map(|q| (q, None)).ok_or_else(|| bad(cmd, format!("no {kind} to snap to near `{pk}`")));
}
p.get(pk)
.and_then(vec3)
.map(|v| (v, None))
.ok_or_else(|| bad(cmd, format!("give `{pk}`, `{fk}` or `{pk}_plane` ([x, y, z], \"origin\", or a plane)")))
p.get(pk).and_then(vec3).map(|v| (v, None)).ok_or_else(|| bad(cmd, format!("give `{pk}` or `{fk}` as [x, y, z]")))
};
let (from, from_normal) = pick(s, "from", "from_face", &from_plane)?;
let (to, to_normal) = pick(s, "to", "to_face", &to_plane)?;
// Faces turn only to meet another face or a plane; a plane's normal is read when evaluated.
let (from, from_normal) = pick(s, "from", "from_face")?;
let (to, to_normal) = pick(s, "to", "to_face")?;
let (from_normal, to_normal) = match (from_normal, to_normal) {
(Some(a), Some(b)) => (Some(a), Some(b)),
(a, b) if from_plane.is_some() || to_plane.is_some() => (a, b),
_ => (None, None),
};
let flip = bool_(p, "flip").unwrap_or(false);
@@ -438,8 +415,6 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
from_normal: from_normal.map(Dir),
to_normal: to_normal.map(Dir),
flip,
from_plane,
to_plane,
},
)
}
@@ -540,57 +540,6 @@ fn replace_face_align_remove() {
assert_eq!(measure(&mut s)["body_count"], 1);
}
/// The z range and the x minimum of body `name`.
fn z_x(s: &mut Session, name: &str) -> (f64, f64, f64) {
let m = measure(s);
let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == name).unwrap().clone();
let g = |k: &str, i: usize| b["bbox"][k][i].as_f64().unwrap_or(f64::NAN);
(g("min", 2), g("max", 2), g("min", 0))
}
/// Align onto planes and the origin: a face goes flush onto an origin plane (turning to meet it),
/// onto an offset plane, a vertex onto the origin point, and a plane the bodies are aligned to
/// is followed when it moves.
#[test]
fn align_takes_planes_and_the_origin() {
let boxed = || {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [50, 0, 20], "body_name": "C"}));
s
};
// The bottom face onto XY: it already faces down, so it only drops; nothing slides across.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "XY"}));
let (lo, hi, x) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6 && (x - 50.0).abs() < 1e-6, "{lo} {hi} {x}");
// The top face onto XY: the body turns over (the face now faces down) and sits on the plane.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 30], "to_plane": "XY"}));
let (lo, hi, _) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && (hi - 10.0).abs() < 1e-6, "{lo} {hi}");
assert!(measure(&mut s)["bodies"][0]["bbox"]["max"][2].as_f64().is_some());
// A vertex onto the origin point.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from": {"snap": "vertex", "at": [50, 0, 20]}, "to": "origin"}));
let (lo, _, x) = z_x(&mut s, "C");
assert!(lo.abs() < 1e-6 && x.abs() < 1e-6, "{lo} {x}");
// Onto an offset plane, and the bodies follow it when its distance changes.
let mut s = boxed();
run(&mut s, "parameters.add", json!({"name": "lift", "expression": "30 mm"}));
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": "lift", "name": "Shelf"}));
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_face": [55, 5, 20], "to_plane": "Shelf"}));
assert!((z_x(&mut s, "C").0 - 30.0).abs() < 1e-6);
run(&mut s, "parameters.change", json!({"name": "lift", "expression": "45 mm"}));
assert!((z_x(&mut s, "C").0 - 45.0).abs() < 1e-6, "the bodies follow the plane");
// A plane as the source: the bodies move with it onto a point.
let mut s = boxed();
run(&mut s, "solid.align", json!({"bodies": ["C"], "from_plane": "XY", "to": [0, 0, 7]}));
assert!((z_x(&mut s, "C").0 - 27.0).abs() < 1e-6);
// One side is a plane or a point, not both; an unknown plane is refused.
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from_plane": "XY", "from": [0, 0, 0], "to": [0, 0, 7]})).is_err());
assert!(s.execute("solid.align", &json!({"bodies": ["C"], "from": [0, 0, 0], "to_plane": "Nowhere"})).is_err());
}
#[test]
fn coil_and_loft_to_a_point() {
use std::f64::consts::{PI, TAU};
+5 -6
View File
@@ -20,11 +20,11 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("open")
.key("Ctrl+O")
.noundo()
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: auto|solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
.params("path: .solidcraft design, or a .step/.stp/.igs/.fcstd/.3dm/.3mf/.stl/.obj/.glb/.gltf file (STEP, IGES, FreeCAD, Rhino, 3MF, STL, OBJ or glTF; opens as a new design holding the file as a component named after it, as file.insert_step and file.insert_mesh make); up?: z|y for a STEP, IGES, FreeCAD, Rhino or glTF file (see file.insert_step and file.insert_mesh); names?: solid|product|instance for a STEP, IGES, FreeCAD or Rhino file (see file.insert_step)"),
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: auto|solid|product|instance (default auto: keep meaningful solid names, replace placeholders with the nearest named product, else instance; solid: the solid's own name; product: the part's name; instance: the occurrence's name, else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
@@ -89,10 +89,9 @@ fn step_options(p: &Value, cmd: &str) -> Result<StepOptions> {
};
let names = match p.get("names") {
None | Some(Value::Null) => solidcraft_io::StepNames::default(),
Some(v) => v
.as_str()
.and_then(solidcraft_io::StepNames::parse)
.ok_or_else(|| bad(cmd, "`names` must be \"auto\", \"solid\", \"product\" or \"instance\""))?,
Some(v) => {
v.as_str().and_then(solidcraft_io::StepNames::parse).ok_or_else(|| bad(cmd, "`names` must be \"solid\", \"product\" or \"instance\""))?
}
};
Ok(StepOptions { y_up, names })
}
+3 -3
View File
@@ -49,7 +49,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.noundo()
.doc_only()
.contract(&DESCRIBE_CONTRACT)
.output(crate::schema::output_schema_for::<crate::schema::inspect::DescribeResult>)
.output(crate::schema::output_schema_for::<crate::schema::DescribeResult>)
.params("command: a command id. Returns {id, label, params, input, output} (and param_warnings in the warn parameter-check mode): the command's parameter documentation and, once the command has a typed contract, the JSON Schemas (2020-12) of its parameters and of a successful result (null otherwise). Read-only."),
];
@@ -326,13 +326,13 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"body": b.name, "faces": faces}))
}
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::inspect::DescribeParams> = crate::schema::TypedContract::new();
const DESCRIBE_CONTRACT: crate::schema::TypedContract<crate::schema::DescribeParams> = crate::schema::TypedContract::new();
/// A command's prose documentation and, for converted commands, its schemas.
fn describe(_s: &mut Session, p: &Value) -> Result<Value> {
let id = p.get("command").and_then(Value::as_str).ok_or_else(|| crate::params::bad("engine.describe", "`command` must be a command id"))?;
let spec = super::find_command(id).ok_or_else(|| crate::EngineError::UnknownCommand(id.to_string()))?;
let reply = crate::schema::inspect::DescribeResult {
let reply = crate::schema::DescribeResult {
id: spec.id.to_string(),
label: spec.label.to_string(),
params: spec.params.to_string(),
-2
View File
@@ -35,9 +35,7 @@ mod edit;
mod face;
mod features;
mod features_more;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach};
pub use sketch_modify::trim_piece;
mod component_link;
-127
View File
@@ -616,61 +616,6 @@ fn matrix_move_turns_a_quarter_and_moves() {
}
}
/// #3223, #2872: a STEP assembly whose part sits two levels down, each level placed, opened and
/// stood up. With the imported root active, as Open leaves it, a circle sketched on the part's
/// top face cuts it through, reads as a Cut dragged in and a Join dragged out, and joins it in
/// one piece instead of starting a body of the root's own.
#[test]
fn a_stood_up_nested_step_part_is_cut_and_joined_from_its_face() {
let dir = std::env::temp_dir().join(format!("solidcraft-nested-stood-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("nested.step");
let mut src = Session::default();
let (_, carrier) = created(&mut src, "Carrier");
let (part, nested) = created(&mut src, "Nested Part");
run(&mut src, "solid.box", json!({"length": 40, "width": 24, "height": 10, "body_name": "NestedBlock"}));
run(&mut src, "occurrence.move", json!({"occurrence": carrier, "translate": [12, 7, 5], "axis": [0, 0, 1], "angle": "30 deg"}));
run(&mut src, "occurrence.move", json!({"occurrence": nested, "translate": [3, 11, 2], "axis": [0, 1, 0], "angle": "20 deg"}));
// The middle of the top face, in the file's frame: the imported root's.
let top = solidcraft_doc::apply_point(&src.doc.component_transform(part), solidcraft_geom::Vec3::new(20.0, 12.0, 10.0));
run(&mut src, "file.export", json!({"path": path, "format": "step"}));
let mut s = Session::default();
run(&mut s, "doc.open", json!({"path": path, "up": "y"}));
let _ = std::fs::remove_dir_all(&dir);
let at = solidcraft_doc::apply_point(&s.doc.component_transform(s.active_component), top);
run(&mut s, "sketch.create", json!({"plane": {"face": [at.x, at.y, at.z]}, "project_edges": false}));
let centre = s
.doc
.features
.iter()
.rev()
.find_map(|f| match &f.kind {
solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, .. }, .. } => Some(plane.to_local(top)),
_ => None,
})
.unwrap();
run(&mut s, "sketch.circle.center", json!({"center": [centre.x, centre.y], "radius": 3}));
run(&mut s, "sketch.finish", json!({}));
assert_eq!(crate::auto_operation(&s, &json!({"distance": -5})), Some("cut"));
assert_eq!(crate::auto_operation(&s, &json!({"distance": 5})), Some("join"));
let volume = |s: &mut Session| run(s, "inspect.measure", json!({"bodies": ["NestedBlock"]}))["total"]["volume_mm3"].as_f64().unwrap();
let block = 40.0 * 24.0 * 10.0;
let hole = std::f64::consts::PI * 3.0 * 3.0;
// The circle is measured as faceted: within a thousandth of what it removes or adds.
for operation in ["cut", "auto"] {
run(&mut s, "solid.extrude", json!({"distance": -20, "operation": operation}));
assert!((block - volume(&mut s) - hole * 10.0).abs() < 1e-3 * hole * 10.0, "{operation}: {}", volume(&mut s));
run(&mut s, "edit.undo", json!({}));
}
run(&mut s, "solid.extrude", json!({"distance": 5, "operation": "join"}));
let st = s.model.state();
assert_eq!(st.bodies.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["NestedBlock"]);
assert_eq!(st.body("NestedBlock").map(|b| b.body.shell_count()), Some(1));
assert!((volume(&mut s) - block - hole * 5.0).abs() < 1e-3 * hole * 5.0, "join: {}", volume(&mut s));
}
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
/// is refused with the reason, and the occurrence stays where it was.
#[test]
@@ -717,75 +662,3 @@ fn matrix_move_orthonormalises_rounding_and_refuses_what_is_not_rigid() {
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform, before);
run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": rounded, "tolerance": 1e-3}));
}
/// #3383: a combine across components meets the tool where it is shown, not where its numbers
/// would be in the target's own frame. The plate in a component turned a quarter about X sits at
/// y -10..0, z -20..20; the boss in the root overlaps it by 10 x 5 x 5. The reverse (a peg in the
/// turned component cut from a root plate) removes 10 x 10 x 5 of it, not 10 x 10 x 10.
#[test]
fn combine_across_a_turned_occurrence_meets_the_tool_where_it_is_shown() {
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
let turned = |s: &mut Session, body: Value| {
created(s, "Part");
run(s, "solid.box", body);
run(s, "component.activate", json!({"component": "root"}));
run(s, "occurrence.move", json!({"occurrence": "Part:1", "axis": [1, 0, 0], "angle": "90 deg"}));
run(s, "component.capture_position", json!({}));
};
let mut s = Session::default();
turned(&mut s, json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "body_name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, -25], "operation": "new", "body_name": "Boss"}));
run(&mut s, "solid.combine", json!({"target": "Plate", "tools": ["Boss"]}));
assert_eq!(s.model.state().body("Plate").map(|b| b.body.shell_count()), Some(1));
let want = 40.0 * 40.0 * 10.0 + 10.0 * 10.0 * 10.0 - 10.0 * 5.0 * 5.0;
assert!((volume(&s, "Plate") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Plate"));
let mut s = Session::default();
turned(&mut s, json!({"length": 10, "width": 10, "height": 10, "corner": [-5, -5, 0], "body_name": "Peg"}));
run(&mut s, "solid.box", json!({"length": 40, "width": 40, "height": 10, "corner": [-20, -20, 0], "operation": "new", "body_name": "Base"}));
run(&mut s, "solid.combine", json!({"target": "Base", "tools": ["Peg"], "operation": "cut"}));
let want = 40.0 * 40.0 * 10.0 - 10.0 * 10.0 * 5.0;
assert!((volume(&s, "Base") - want).abs() < 1e-6 * want, "{} vs {want}", volume(&s, "Base"));
}
/// #3383: a feature joining into a body of another, placed component draws its tool where the
/// tool is shown, as Combine does. Part is moved 100 mm along x, so its 10 mm cubes sit at x 100..110
/// and 120..130; a box and an extrude drawn from the root over each add 4 x 4 x 5 to it in one
/// piece, not a separate lump 100 mm further on. A through-all cut is as long as the target is
/// far where it is shown.
#[test]
fn joins_into_a_moved_occurrence_meet_the_tool_where_it_is_shown() {
let volume = |s: &Session, name: &str| s.model.state().body(name).map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).unwrap_or(0.0);
let mut s = Session::default();
created(&mut s, "Part");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "operation": "new", "body_name": "B"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 0]}));
run(&mut s, "component.capture_position", json!({}));
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 15, "corner": [103, 3, 0], "operation": "join", "targets": ["A"]}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [123, 3], "p1": [127, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": 15, "operation": "join", "targets": ["B"]}));
let want = 10.0 * 10.0 * 10.0 + 4.0 * 4.0 * 5.0;
for b in ["A", "B"] {
assert_eq!(s.model.state().body(b).map(|b| b.body.shell_count()), Some(1), "{b}");
assert!((volume(&s, b) - want).abs() < 1e-6 * want, "{b}: {} vs {want}", volume(&s, b));
}
// Through all reaches the target where it is shown: Part moved 100 mm along x and up, a cut
// drawn on the root's XY plane goes 100 mm up through its cube, not 10 mm.
let mut s = Session::default();
created(&mut s, "Part");
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "C"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [100, 0, 100]}));
run(&mut s, "component.capture_position", json!({}));
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [103, 3], "p1": [107, 7]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"through_all": true, "operation": "cut", "targets": ["C"]}));
let want = 10.0 * 10.0 * 10.0 - 4.0 * 4.0 * 10.0;
assert!((volume(&s, "C") - want).abs() < 1e-6 * want, "C: {} vs {want}", volume(&s, "C"));
}
-26
View File
@@ -549,29 +549,3 @@ fn copies_of_a_primitive_cut_only_its_targets() {
assert!(rel(removed, holes * PI * 9.0 * 10.0) < 1e-3, "{id}: {removed} mm³ cut from Plaque");
}
}
/// #3378: a pattern or mirror of a join whose targets the join merged into one body copies onto
/// that body, as before #2755, instead of looking for the target the join used up.
#[test]
fn copies_of_a_join_that_merged_its_targets_join_the_merged_body() {
for (copy, n) in [
(json!(["solid.pattern.circular", {"features": ["spoke"], "axis": "Y", "count": 5}]), 5.0),
(json!(["solid.mirror", {"features": ["spoke"], "plane": {"origin": [0, 0, 0], "normal": [1, 0, 0]}}]), 2.0),
] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 66, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "rim"}));
run(&mut s, "solid.cylinder", json!({"radius": 63, "height": 3, "base": [0, 0, 0], "axis": "Y", "operation": "cut", "targets": ["rim"]}));
run(&mut s, "solid.cylinder", json!({"radius": 12, "height": 3, "base": [0, 0, 0], "axis": "Y", "body_name": "hub"}));
let v0 = volume(&mut s);
let spoke = json!({"length": 52, "width": 3, "height": 2.5, "corner": [12, 0, -1.25], "operation": "join", "targets": ["rim", "hub"], "name": "spoke"});
run(&mut s, "solid.box", spoke);
let v1 = volume(&mut s);
let (id, p) = (copy[0].as_str().unwrap_or_default(), copy[1].clone());
run(&mut s, id, p);
let names = run(&mut s, "inspect.measure", json!({}))["bodies"].as_array().map(|b| b.iter().map(|x| x["name"].clone()).collect::<Vec<_>>());
assert_eq!(names, Some(vec![json!("rim")]), "{id}: one body, the merged rim and hub");
// Every copy adds what the seed spoke added (curved bodies measure to about 0.02 %; a
// missing or doubled spoke is about 5 %).
assert!(rel(volume(&mut s), v0 + n * (v1 - v0)) < 1e-3, "{id}: {} vs {}", volume(&mut s), v0 + n * (v1 - v0));
}
}
+1 -4
View File
@@ -359,10 +359,7 @@ fn designs_from_before_bend_edges_open_unchanged() {
assert!(!older.to_string().contains("\"bends\""), "{older}");
older["format"] = json!("solidcraft/16");
let doc = solidcraft_doc::Document::from_json(&older.to_string()).unwrap();
// An older design keeps the numbering of the pieces it was saved with (#3902).
let mut same = solidcraft_doc::Document::clone(&s.doc);
same.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
assert_eq!(doc, same);
assert_eq!(doc, *s.doc);
let mut reopened = Session::new(doc);
assert!(reopened.model.results.iter().all(|x| x.error.is_none()));
assert!(same_size(flat_size(&mut reopened), (100.0, 60.0), 1e-9), "{:?}", flat_size(&mut reopened));
-105
View File
@@ -4878,108 +4878,3 @@ fn a_sheet_made_of_sheet_features_and_flat_cuts_still_exports_its_flat_pattern()
}
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(2));
}
/// An L-bracket with a hole off its centre and two flanges, so that its mirror image is the
/// opposite hand: a 100 x 60 base on XY with a 10 mm hole at (25, 40), a 20 mm flange on the
/// front edge and a 15 mm one on the right edge.
fn asymmetric_bracket(s: &mut Session) {
run(s, "sheet.manage_rules", json!({"name": "S", "thickness": "2 mm", "bend_radius": "2 mm", "k_factor": 0.4, "active": true}));
run(s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(s, "sketch.finish", json!({}));
run(s, "sheet.flange", json!({"type": "base"}));
run(s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "name": "F1"}));
run(s, "sheet.flange", json!({"edges": [[100, 30, 2]], "height": 15, "name": "F2"}));
circle_sketch(s, "Hole", json!("XY"), [25.0, 40.0], 10.0);
run(s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
}
/// The points of a flat pattern's loops, `x` scaled by `sx`, sorted, rounded to 1e-6 mm.
fn flat_points(v: &Value, sx: f64) -> Vec<(i64, i64)> {
let mut out: Vec<(i64, i64)> = v
.as_array()
.into_iter()
.flatten()
.flat_map(|l| l.as_array().cloned().unwrap_or_default())
.map(|p| ((p[0].as_f64().unwrap_or(f64::NAN) * sx * 1e6).round() as i64, (p[1].as_f64().unwrap_or(f64::NAN) * 1e6).round() as i64))
.collect();
out.sort_unstable();
out
}
fn body_volume(s: &mut Session, name: &str) -> f64 {
let m = run(s, "inspect.measure", json!({}));
m["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == name).and_then(|b| b["volume_mm3"].as_f64()).unwrap_or(f64::NAN)
}
#[test]
fn a_mirrored_sheet_copy_is_the_opposite_hand_with_the_mirror_image_flat_pattern() {
let mut s = Session::default();
asymmetric_bracket(&mut s);
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
let mirrored = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1 (2)"}));
// The same blank, the other way round: K-factor flat length, bends and thickness are kept.
assert_eq!(mirrored["flat_size_mm"], original["flat_size_mm"], "{mirrored}");
assert_eq!(mirrored["thickness"], original["thickness"]);
assert_eq!(flat_points(&mirrored["outline"], 1.0), flat_points(&original["outline"], -1.0));
assert_eq!(original["cutouts"].as_array().map(Vec::len), Some(1), "{original}");
assert_eq!(flat_points(&mirrored["cutouts"], 1.0), flat_points(&original["cutouts"], -1.0));
let bends = |f: &Value| -> Vec<(String, i64, i64)> {
let mut b: Vec<_> = f["bends"]
.as_array()
.into_iter()
.flatten()
.map(|b| {
(
b["direction"].to_string(),
(b["allowance"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
(b["length"].as_f64().unwrap_or(0.0) * 1e9).round() as i64,
)
})
.collect();
b.sort();
b
};
assert_eq!(bends(&mirrored), bends(&original));
assert_eq!(bends(&mirrored).len(), 2);
// The hole is where the mirror put it, on the copy and in its flat pattern.
let in_copy = |p: [f64; 3]| {
s.model.state().bodies.iter().find(|b| b.name == "Body1 (2)").is_some_and(|b| b.body.contains(solidcraft_geom::Vec3::new(p[0], p[1], p[2])))
};
assert!(!in_copy([-25.0, 40.0, 1.0]) && in_copy([-35.0, 40.0, 1.0]));
let hole = flat_points(&mirrored["cutouts"], 1.0);
let cx = hole.iter().map(|p| p.0 as f64).sum::<f64>() / hole.len().max(1) as f64 * 1e-6;
assert!((cx + 25.0).abs() < 1e-3, "hole centre x {cx}");
// The sheet model is the mirrored body: a flange added to the copy grows it from its own
// back edge, and its DXF is that of a sheet.
let before = body_volume(&mut s, "Body1 (2)");
assert!(rel(before, body_volume(&mut s, "Body1")) < 1e-9);
run(&mut s, "sheet.flange", json!({"edges": [[-50, 60, 2]], "height": 10, "body": "Body1 (2)"}));
let after = body_volume(&mut s, "Body1 (2)");
assert!(after > before + 100.0, "{before} -> {after}");
let dxf = run(&mut s, "sheet.export_dxf", json!({"body": "Body1 (2)"}));
assert_eq!(dxf["bends"], 3);
}
#[test]
fn mirroring_or_copying_a_sheet_body_leaves_the_original_sheet_as_it_was() {
let mut s = Session::default();
asymmetric_bracket(&mut s);
let original = run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"}));
let volume = body_volume(&mut s, "Body1");
run(&mut s, "solid.mirror", json!({"bodies": ["Body1"], "plane": "YZ"}));
run(&mut s, "solid.move", json!({"bodies": ["Body1"], "translate": [0, 200, 0], "copy": true}));
run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["Body1"], "dir1": [0, 0, 1], "count1": 2, "spacing1": 100}));
assert_eq!(run(&mut s, "sheet.flat_pattern", json!({"body": "Body1"})), original);
assert!(rel(body_volume(&mut s, "Body1"), volume) < 1e-9);
// A moved or patterned copy is the same hand: the same flat pattern, as a sheet of its own.
let names: Vec<String> = s.model.state().bodies.iter().map(|b| b.name.clone()).collect();
assert_eq!(names.len(), 4, "{names:?}");
for name in names.iter().filter(|n| *n != "Body1" && *n != "Body1 (2)") {
let mut copy = run(&mut s, "sheet.flat_pattern", json!({"body": name}));
copy["body"] = original["body"].clone();
assert_eq!(flat_points(&copy["outline"], 1.0), flat_points(&original["outline"], 1.0), "{name}");
assert_eq!(copy["bends"], original["bends"], "{name}");
}
}
+2 -24
View File
@@ -1864,11 +1864,8 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
let cb = sk.curve_index(rb).ok_or_else(|| bad(cmd, format!("unknown entity `{rb}`")))?;
match (sk.curves.get(ca).map(|c| c.kind.clone()), sk.curves.get(cb).map(|c| c.kind.clone())) {
(Some(CurveKind::Line { a: a0, b: a1 }), Some(CurveKind::Line { a: b0, b: b1 })) => {
// Opposite sides of a rectangle run in opposite directions: pair each end
// with the end of the other line nearest its mirror image (#2961, #2940).
let (p0, p1) = if mirror_pairing_is_crossed(sk, l, [a0, a1], [b0, b1]) { (b1, b0) } else { (b0, b1) };
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: p0, l })?);
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: p1, l })?);
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: b0, l })?);
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: b1, l })?);
}
(Some(_), Some(_)) => {
let pa = round_center(sk, ca).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?;
@@ -1889,25 +1886,6 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"constraints": ids, "sketch": info}))
}
/// True when the ends of line `a` mirror (about the line `l`) closer onto the ends of `b` crossed
/// (`a0` with `b1`, `a1` with `b0`) than in order. A degenerate symmetry line keeps the order.
fn mirror_pairing_is_crossed(sk: &Sketch, l: usize, a: [usize; 2], b: [usize; 2]) -> bool {
let Some((o, e)) = line_pts(sk, l) else { return false };
let d = e - o;
let len2 = d.x * d.x + d.y * d.y;
if len2 < 1e-18 {
return false;
}
let pt = |i: usize| sk.point(i).unwrap_or_default();
let near = |i: usize, j: usize| {
let v = pt(i) - o;
let t = (v.x * d.x + v.y * d.y) / len2;
let foot = o + d * t;
(foot * 2.0 - pt(i)).dist(pt(j))
};
near(a[0], b[1]) + near(a[1], b[0]) < near(a[0], b[0]) + near(a[1], b[1])
}
/// Symmetry about point `c`: two points, two circles/arcs (their centres, and equal radii) or two
/// lines (each end with the end of the other line nearest its mirror image). The constraints
/// (the construction lines show in the response's `created`).
@@ -374,66 +374,6 @@ fn symmetry_moves_a_free_line_between_two_fixed_lines() {
}
}
/// A 20 x 10 rectangle (sides l1..l4; left = l4 and right = l2 run in opposite directions) and a
/// free vertical line at x = -20 as the symmetry line (#2940, #2961).
fn rectangle_and_axis(dimension_left: bool) -> (Session, Vec<String>) {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, 0], "p1": [8, 10]}));
let sides = ids(&r["curves"]);
run(&mut s, "sketch.line", json!({"points": [[-20, -5], [-20, 15]], "construction": true, "ids": ["axis"]}));
if dimension_left {
run(&mut s, "sketch.dimension", json!({"entities": [sides[3].clone()], "value": 10}));
}
(s, sides)
}
/// Both sides keep their 10 mm height and their ends mirror in x about `axis_x`.
fn assert_sides_mirror_with_height(s: &Session, left: &str, right: &str, axis_x: f64) {
let sk = sketch(s);
let at = |r: String| sk.point(sk.resolve_point(&r).unwrap()).unwrap();
for side in [left, right] {
let (a, b) = (at(format!("{side}.start")), at(format!("{side}.end")));
assert!((a.y - b.y).abs() > 9.999 && (a.y - b.y).abs() < 10.001, "{side} height {:?} {:?}", a, b);
assert!((a.x - b.x).abs() < 1e-7, "{side} not vertical: {:?} {:?}", a, b);
}
let (l, r) = (at(format!("{left}.start")), at(format!("{right}.start")));
assert!((l.x + r.x - 2.0 * axis_x).abs() < 1e-6, "sides not mirrored: {l:?} {r:?}");
}
#[test]
fn symmetry_of_rectangle_sides_keeps_their_height() {
// #2940: the opposite sides of a rectangle run in opposite directions, so pairing their ends
// by index forced both heights to 0.
let (mut s, sides) = rectangle_and_axis(false);
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "axis"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
}
#[test]
fn symmetry_of_a_dimensioned_rectangle_side_is_accepted() {
// #2961: with a length on a side, the same symmetry was refused as "neither entity can move".
let (mut s, sides) = rectangle_and_axis(true);
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "axis"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
}
#[test]
fn symmetry_of_rectangle_sides_with_swapped_arguments() {
let (mut s, sides) = rectangle_and_axis(true);
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[1], "b": sides[3], "line": "axis"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], -20.0);
}
#[test]
fn symmetry_of_rectangle_sides_about_the_y_axis() {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-12, 0], "p1": [8, 10]}));
let sides = ids(&r["curves"]);
run(&mut s, "sketch.dimension", json!({"entities": [sides[3].clone()], "value": 10}));
run(&mut s, "sketch.constraint.symmetry", json!({"a": sides[3], "b": sides[1], "line": "y"}));
assert_sides_mirror_with_height(&s, &sides[3], &sides[1], 0.0);
}
#[test]
fn midpoint_alignment_persists_undoes_and_survives_endpoint_edits() {
use solidcraft_sketch::ConstraintKind;
+4 -41
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use solidcraft_geom::Vec2;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextExprs, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
@@ -822,39 +822,6 @@ fn guess_radius(sk: &Sketch, curves: &[usize], at: Vec2) -> f64 {
if r.is_finite() && r > 1e-6 { r } else { 1.0 }
}
/// Where the centre of a circle of radius `d` touching `sh` can lie: the line moved `d` to either
/// side, or the circle grown and shrunk by `d` (arcs as their full circle).
fn centre_paths(sh: &Shape, d: f64) -> Vec<Shape> {
match *sh {
Shape::Line { a, b } => {
(b - a).normalized().map(|u| [d, -d].map(|s| Shape::Line { a: a + u.perp() * s, b: b + u.perp() * s }).to_vec()).unwrap_or_default()
}
Shape::Round { c, r, .. } => [r + d, (r - d).abs()]
.into_iter()
.filter(|x| *x > 1e-12)
.map(|r| Shape::Round { c, r, start: 0.0, sweep: std::f64::consts::TAU })
.collect(),
}
}
/// The centre nearest `near` of a circle of radius `r` touching curves `a` and `b` (#3141): the
/// solver starts from the answer instead of from the click, which lies on a curve, where the
/// tangency has no side yet and the solve fails or moves the curves.
fn tangent_centre(sk: &Sketch, a: usize, b: usize, r: f64, near: Vec2) -> Option<Vec2> {
let (sa, sb) = (sk.shape(a)?, sk.shape(b)?);
let mut best: Option<Vec2> = None;
for x in centre_paths(&sa, r) {
for y in centre_paths(&sb, r) {
for q in solidcraft_sketch::intersections_within(&x, &y, 1e-9 * (1.0 + r)) {
if q.x.is_finite() && q.y.is_finite() && best.is_none_or(|b| q.dist(near) < b.dist(near)) {
best = Some(q);
}
}
}
}
best
}
fn tangent_curves(sk: &Sketch, p: &Value, cmd: &str, n: usize) -> Result<Vec<usize>> {
let ids = string_list(p, "curves");
if ids.len() != n {
@@ -875,14 +842,10 @@ fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Optio
if !(r0 > 1e-9 && r0 < 1e8) {
return Err(bad(cmd, "radius must be positive"));
}
// Start where the circle already touches when that can be worked out (two curves and a
// radius), so that only the new circle moves.
let start = match cs.as_slice() {
[a, b] if radius.is_some() => tangent_centre(sk, *a, *b, r0, near).unwrap_or(near),
_ => near,
};
// Start where the circle already nearly touches: move the centre so the guessed circle
// sits between the curves.
let first_new = sk.points.len();
let c = sk.add_circle(start, r0, None, None)?;
let c = sk.add_circle(near, r0, None, None)?;
let mut cons = Vec::new();
for k in &cs {
cons.push(add_c(sk, ConstraintKind::Tangent { a: *k, b: c })?);
@@ -723,17 +723,3 @@ fn a_custom_plane_origin_that_is_not_numbers_is_refused_not_zeroed() {
assert_eq!(origin_of(&mut s, json!({"origin": [20, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([20.0, 0.0, 0.0]));
assert_eq!(origin_of(&mut s, json!({"x_dir": [0, 1, 0], "y_dir": [0, 0, 1]})), json!([0.0, 0.0, 0.0]));
}
/// A sketch on a body's face projects that face's edges from the body's display mesh. The body
/// it projects from is the model's own, not a copy that meshes it again: on a body whose curved
/// edges are slow to mesh, the copy's mesh was thrown away and the next feature meshed the body
/// a second time, which made a sketch on a face as slow as a cut.
#[test]
fn a_sketch_on_a_face_meshes_its_body_once() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base","name":"BaseFeature"}));
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_none());
let pick = top_pick(&s, "Base");
run(&mut s, "sketch.create", json!({"name":"Attached","plane":pick}));
run(&mut s, "sketch.finish", json!({}));
assert!(s.model.state().body("Base").unwrap().cached_mesh().is_some(), "the sketch's projection meshed a copy of the body");
}
@@ -438,51 +438,6 @@ fn new_creation_tools() {
assert!(a.iter().any(|x| close(*x, pi * 5.0 * 2.0 + pi)), "{a:?}");
}
/// #3141: a 2-tangent circle asked for near a point on one of its curves (where a click on that
/// curve puts it) is solved from the touching circle nearest that point: it lands in the corner,
/// the rectangle stays as drawn, and fixed sides no longer make it fail.
#[test]
fn two_tangent_circle_from_a_click_on_a_curve_leaves_the_curves_as_drawn() {
let side = |s: &Session, sides: &[String], on: fn(Vec2) -> bool| -> String {
let sk = sketch(s);
sides
.iter()
.find(|c| {
let i = sk.curve_index(c).unwrap();
matches!(sk.shape(i), Some(solidcraft_sketch::Shape::Line { a, b }) if on(a) && on(b))
})
.unwrap()
.clone()
};
let points = |s: &Session| sketch(s).points.iter().map(|p| p.pos).collect::<Vec<_>>();
for fixed in [false, true] {
let mut s = new_sketch();
let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-800, 0], "p1": [-300, 500]}));
let sides = ids(&r["curves"]);
if fixed {
run(&mut s, "sketch.constraint.fix", json!({"entities": sides}));
}
let before = points(&s);
let bottom = side(&s, &sides, |p| p.y.abs() < 1e-9);
let left = side(&s, &sides, |p| (p.x + 800.0).abs() < 1e-9);
// Midway between a click on the bottom side and one on the left side.
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [bottom, left], "radius": 50, "near": [-675, 125]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let ci = sk.curve_index(&c).unwrap();
assert!(sk.center(ci).unwrap().dist(Vec2::new(-750.0, 50.0)) < 1e-6, "fixed {fixed}: {:?}", sk.center(ci));
assert!(close(sk.radius(ci).unwrap(), 50.0));
let after = points(&s);
assert!(before.iter().zip(&after).all(|(a, b)| a.dist(*b) < 1e-9), "fixed {fixed}: the rectangle did not move: {after:?}");
// Asked for right on the left side: still a touching circle next to it.
let r = run(&mut s, "sketch.circle.two_tangent", json!({"curves": [bottom, left], "radius": 20, "near": [-800, 30]}));
let c = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
let o = sk.center(sk.curve_index(&c).unwrap()).unwrap();
assert!(close(o.y, 20.0) && close((o.x + 800.0).abs(), 20.0), "fixed {fixed}: {o:?}");
}
}
#[test]
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
-56
View File
@@ -17,62 +17,6 @@ fn body(s: &mut Session, b: &str) -> Value {
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0].clone()
}
fn stepped_block() -> Session {
let mut s = Session::default();
run(&mut s, "parameters.add", json!({"name": "step_height", "expression": "10 mm"}));
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 10, "body_name": "Block"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -5], "p1": [5, 5]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance": "step_height", "operation": "join"}));
s
}
/// A column beside a step ends at the step's top, including when the To point lies on an
/// edge or corner shared with a vertical wall (#3729).
#[test]
fn extrude_to_a_planar_face_boundary_ends_at_its_plane() {
for to in [[5, 0, 20], [5, 5, 20], [0, 0, 20], [1000, 1000, 20]] {
let mut s = stepped_block();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"to": to, "operation": "join"}));
let b = body(&mut s, "Block");
let top = b["bbox"]["max"][2].as_f64().unwrap();
let volume = b["volume_mm3"].as_f64().unwrap();
assert!((top - 20.0).abs() < 1e-6, "To {to:?}: z max {top}");
assert!((volume - (5000.0 + 10.0 * PI * 1.5 * 1.5)).abs() < 0.1, "To {to:?}: volume {volume}");
}
}
/// The face chosen at an edge must also be the stored reference: raising the step must raise
/// the end of the extrusion, including after a save/reload.
#[test]
fn extrude_to_a_planar_face_boundary_follows_it_after_reload() {
for to in [[5, 0, 20], [5, 5, 20]] {
let mut s = stepped_block();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 30], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}}));
run(&mut s, "sketch.circle.center", json!({"center": [8, 0], "radius": 1.5}));
run(&mut s, "sketch.finish", json!({}));
// A negative reach with an offset past the target, away from the start.
run(&mut s, "solid.extrude", json!({"to": to, "to_offset": 2, "operation": "new", "body_name": "Post"}));
let b = body(&mut s, "Post");
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 18.0).abs() < 1e-6, "{b}");
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let mut cold = Session::new(doc);
for current in [&mut s, &mut cold] {
run(current, "parameters.change", json!({"name": "step_height", "expression": "15 mm"}));
let b = body(current, "Post");
let volume = b["volume_mm3"].as_f64().unwrap();
assert!((b["bbox"]["min"][2].as_f64().unwrap() - 23.0).abs() < 1e-6, "{b}");
assert!((b["bbox"]["max"][2].as_f64().unwrap() - 30.0).abs() < 1e-6, "{b}");
assert!((volume - 7.0 * PI * 1.5 * 1.5).abs() < 0.1, "{b}");
assert!(current.model.results.iter().all(|r| r.error.is_none() && r.warning.is_none()), "{:?}", current.model.results);
}
}
}
/// A square post extruded up to a horizontal cylinder (axis along x, r 10, centre z 30): its
/// top takes the cylinder's shape, so the post is shortest in the middle.
#[test]
-41
View File
@@ -374,44 +374,3 @@ fn drilled_sketch_points_break_through_a_sloping_back_face() {
assert!(reopened.model.result(id).unwrap().error.is_none());
near(volume(&mut reopened), left);
}
/// Back (z 0…6) under a smaller Front (z 14…20), and a sketch on Front's top face with points
/// over Front (`in`) and beyond its edges, over Back (`out1`, `out2`).
fn front_and_back(s: &mut Session) {
run(s, "solid.box", json!({"length": 40, "width": 40, "height": 6, "body_name": "Back"}));
run(s, "solid.box", json!({"corner": [10, 10, 14], "length": 20, "width": 20, "height": 6, "body_name": "Front"}));
run(s, "sketch.create", json!({"plane": {"face": [20, 20, 20]}, "name": "Pts", "project_edges": false}));
for (id, p) in [("in", [20, 20]), ("out1", [5, 5]), ("out2", [35, 35])] {
run(s, "sketch.point", json!({"point": p, "id": id}));
}
run(s, "sketch.finish", json!({}));
}
/// #2882: holes at the points of a sketch on a body's face drill that body, never one behind it.
#[test]
fn holes_from_a_sketch_on_a_face_drill_that_body_only() {
let mut s = Session::default();
front_and_back(&mut s);
let base = volume(&mut s);
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in", "out1", "out2"], "diameter": 5, "depth": 4, "name": "H"}));
assert_eq!(s.doc.find_feature("H").unwrap().participants, vec!["Front".to_string()]);
near(base - volume(&mut s), pi() * 6.25 * 4.0);
assert!(!solid_at(&s, 20.0, 20.0, 18.0), "drilled into Front");
for (x, y) in [(5.0, 5.0), (35.0, 35.0), (20.0, 20.0)] {
assert!(solid_at(&s, x, y, 5.5) && solid_at(&s, x, y, 3.0), "Back is not drilled at ({x}, {y})");
}
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
near(base - volume(&mut reopened), pi() * 6.25 * 4.0);
}
/// Through all from Front's face stops at Front; `targets` picks another body to drill instead.
#[test]
fn face_sketch_holes_through_all_stay_in_their_body_unless_targets_say_otherwise() {
let mut s = Session::default();
front_and_back(&mut s);
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "name": "H"}));
assert!(!solid_at(&s, 20.0, 20.0, 15.0) && solid_at(&s, 20.0, 20.0, 3.0));
run(&mut s, "edit.undo", json!({}));
run(&mut s, "solid.hole", json!({"sketch": "Pts", "points": ["in"], "diameter": 5, "targets": ["Back"], "name": "H"}));
assert!(solid_at(&s, 20.0, 20.0, 15.0) && !solid_at(&s, 20.0, 20.0, 3.0));
}
+2 -21
View File
@@ -170,19 +170,9 @@ pub struct Snapshot {
pub struct Ahead(Session);
impl Ahead {
/// What the evaluation computes, kept for the run that follows here whatever the evaluation
/// cache drops meanwhile: hand it to [`solidcraft_doc::Model::keep`] for that run.
pub fn kept(&self) -> Arc<solidcraft_doc::KeptResults> {
self.0.model.kept().unwrap_or_default()
}
/// Run `commands` (their results stay in the shared evaluation cache, and in [`Ahead::kept`]),
/// then make the display meshes and plans of the bodies they end with: the bodies the run
/// here finds share them, so the window draws, fits and picks without tessellating anything.
/// Run `commands` (their results stay in the shared evaluation cache).
pub fn evaluate(self, commands: &[(String, Value)]) -> Result<()> {
let (s, _) = self.0.preview_in_place(commands)?;
s.world_state().warm_display();
Ok(())
self.0.preview_in_place(commands).map(|_| ())
}
/// Run `commands` one after another, each reply or error in turn (what they evaluate stays
@@ -441,24 +431,16 @@ impl Session {
/// Become `fresh` (another design opened, recovered or started), keeping the settings that
/// are the user's rather than the design's: `auto_project` is a preference (#680).
/// Checkpoints survive too: a batch that failed after `file.new` or `doc.open` goes back
/// from the checkpoint it took before its first step, and that checkpoint belongs to the
/// design before the batch, which is what the batch promised (#3242). The design is what a
/// checkpoint keeps, not the replaced session's file path (docs/mcp.md).
pub fn replace_design(&mut self, fresh: Session) {
let auto_project = self.auto_project;
let param_check = self.param_check;
let render_viewport = self.render_viewport.clone();
let render_jobs = self.render_jobs.scratch();
let checkpoints = std::mem::take(&mut self.checkpoints);
let next_checkpoint = self.next_checkpoint;
*self = fresh;
self.auto_project = auto_project;
self.param_check = param_check;
self.render_viewport = render_viewport;
self.render_jobs = render_jobs;
self.checkpoints = checkpoints;
self.next_checkpoint = next_checkpoint;
}
/// The design has changes not in its file: in the document, or in what is hidden.
@@ -1040,7 +1022,6 @@ impl Session {
let mut copy = self.scratch();
copy.undo.clone_from(&self.undo);
copy.redo.clone_from(&self.redo);
copy.model.keep(Some(Arc::default()));
Ahead(copy)
}
-50
View File
@@ -278,53 +278,3 @@ fn step_round_trip_of_a_plate_with_holes() {
assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
// A part ring cut by `solid.combine` (two prisms through its ends, a notch through its chamfer)
// measures what it encloses, wherever it is moved. The reference mesher left a crack where the
// notch meets the chamfer's cone; the volume beside it was left out (0.2 %), and integrated
// about the world origin the loss grew with the distance (7 % moved 1000 mm away).
fn volume(s: &mut Session, body: &str) -> f64 {
run(s, "inspect.measure", json!({"bodies": [body]}))["total"]["volume_mm3"].as_f64().unwrap()
}
fn prism(s: &mut Session, offset: f64, points: Value, distance: f64, name: &str) {
run(s, "sketch.create", json!({"plane": "XY", "offset": offset}));
run(s, "sketch.line", json!({"points": points, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.extrude", json!({"distance": distance, "operation": "new", "body_name": name}));
}
/// A part ring (210° of a chamfered section) less two oblique prisms and a notch.
fn cut_ring(s: &mut Session) {
let (c, si) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
run(s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [si, c, 0], "y_dir": [0, 0, -1]}}));
let section = json!([[16.6, 0], [17.5, 0], [18.5, -1.0], [18.5, -7.0], [17.5, -8.0], [16.6, -8.0], [16.4, -7.8], [16.4, -0.2]]);
run(s, "sketch.line", json!({"points": section, "closed": true}));
run(s, "sketch.finish", json!({}));
run(s, "solid.revolve", json!({"axis": "Z", "angle": "210 deg", "body_name": "Ring"}));
let quad = |sy: f64| json!([[4.644832, 15.101839 * sy], [5.17399, 16.822301 * sy], [4.555215, 17.000295 * sy], [2.187187, 15.574386 * sy]]);
prism(s, -1.0, quad(1.0), 10.0, "T0");
prism(s, -1.0, quad(-1.0), 10.0, "T1");
prism(s, 7.1, json!([[-17.6, 0.45], [-19.5, 0.45], [-19.5, -0.45], [-17.6, -0.45]]), 1.9, "N0");
run(s, "solid.combine", json!({"target": "Ring", "tools": ["T0", "T1", "N0"], "operation": "cut"}));
}
#[test]
fn combine_cut_volume_does_not_depend_on_placement() {
let mut s = Session::default();
cut_ring(&mut s);
let here = volume(&mut s, "Ring");
// The 210° ring by Pappus less what the prisms and the notch take (a fine grid quadrature
// of each, independent of the kernel's meshes): 1005.489 − 2 × 5.639 − 0.287.
let want = 993.924;
assert!((here - want).abs() <= 2e-4 * want, "at the origin: {here} vs {want}");
let mut moved = Vec::new();
for by in [[100.0, 0.0, 0.0], [900.0, 0.0, 0.0], [-1000.0, 100.0, 0.0], [0.0, 0.0, 10000.0], [0.0, -10000.0, 0.0]] {
run(&mut s, "solid.move", json!({"bodies": ["Ring"], "translate": by}));
moved.push((by, volume(&mut s, "Ring")));
}
for (by, v) in moved {
assert!((v - here).abs() <= 1e-6 * here, "after a move by {by:?}: {v} vs {here} at the origin");
}
}
-58
View File
@@ -401,61 +401,3 @@ fn a_cut_through_one_of_two_bosses_names_only_its_own_walls() {
assert_eq!(blends, 4, "{filleted:?}");
assert_eq!(named_at(&filleted, [19.98, -4.24, 19.0]), boss);
}
/// #3902: a torus with a closed, twisted square sweep cut inside it splits the sweep's walls into
/// pieces the kernel returns in a different order on each evaluation. The pieces are numbered by
/// where they lie, so every fresh evaluation gives each piece the same name.
#[test]
fn split_pieces_keep_their_numbers_across_evaluations() {
let script: Value = serde_json::from_str(include_str!("../../../examples/regressions/closed_sweep_torus_cut.json")).unwrap();
let mut s = Session::default();
s.run_script(&script).unwrap();
let body = s.model.state().bodies.first().map(|b| b.name.clone()).expect("a body");
let named = |s: &mut Session| -> Vec<(String, [i64; 3])> {
let mut faces: Vec<(String, [i64; 3])> = listed(s, &body).into_iter().map(|(n, _, c)| (n, c.map(|x| x.round() as i64))).collect();
faces.sort();
faces
};
let first = named(&mut s);
assert!(first.iter().any(|(n, _)| n.contains('#')), "no split pieces: {first:?}");
for run in 0..10 {
let mut fresh = Session::new(solidcraft_doc::Document::clone(&s.doc));
fresh.refresh();
assert_eq!(named(&mut fresh), first, "evaluation {run}");
}
}
/// #3902: a design saved before the pieces of a face were numbered by place opens with the
/// numbers it was saved with, so a reference to `…#1` stays on the piece it was made on. The
/// block cut by the second block here has its side split in two, numbered the other way round
/// by place.
#[test]
fn a_design_saved_before_pieces_were_numbered_by_place_keeps_their_numbers() {
let script: Value = serde_json::from_str(include_str!("../../../tests/action_matrix/designs/multi-body-join-cut.json")).unwrap();
let mut s = Session::default();
s.run_script(&script).unwrap();
let by_place = listed(&mut s, "Block");
// As a build before the change saved it: its features carry no mark, and the file does not
// list the change among those it has.
let mut file: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
for f in file["features"].as_array_mut().unwrap() {
f.as_object_mut().unwrap().remove("kernel_piece_order");
}
if let Some(Value::Array(pending)) = file.get_mut("format_pending") {
pending.retain(|c| c != "face-pieces-by-place");
}
let old = solidcraft_doc::Document::from_json(&file.to_string()).unwrap();
assert!(old.features.iter().all(|f| f.kernel_piece_order), "the features of an older design keep the old numbering");
let mut reopened = Session::new(old);
reopened.refresh();
let kept = listed(&mut reopened, "Block");
let at = |faces: &[(String, bool, [f64; 3])], name: &str| faces.iter().find(|(n, _, _)| n == name).map(|(_, _, c)| *c);
// The pieces are numbered differently by place, and the older design keeps its own numbers.
let (old1, new1) = (at(&kept, "F2:side#1").unwrap(), at(&by_place, "F2:side#1").unwrap());
assert!((0..3).any(|k| (old1[k] - new1[k]).abs() > 1.0), "{kept:?} {by_place:?}");
let mut legacy = solidcraft_doc::Document::clone(&s.doc);
legacy.features.iter_mut().for_each(|f| f.kernel_piece_order = true);
let mut saved_with = Session::new(legacy);
saved_with.refresh();
assert_eq!(listed(&mut saved_with, "Block"), kept);
}
+3 -17
View File
@@ -1,6 +1,6 @@
//! Autosave and crash recovery.
//!
//! Each open design keeps its own recovery entry in the recovery folder: `<id>.solidcraft` (the design
//! A running app keeps one recovery entry in the recovery folder: `<id>.solidcraft` (the design
//! as of the last autosave), `<id>.json` (its file path, name and time) and `<id>.lock`, which
//! the app holds locked while it runs. Autosaves are written atomically, only when the design
//! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An
@@ -163,7 +163,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> {
Ok(())
}
/// One open design's autosaver. Keep it with that design when switching tabs.
/// One app's autosaver.
pub struct Autosaver {
pub dir: PathBuf,
pub id: String,
@@ -232,7 +232,7 @@ impl Snapshot {
}
impl Autosaver {
/// Start autosaving into `dir` (created if needed), holding this design's lock.
/// Start autosaving into `dir` (created if needed), holding this app's lock.
pub fn new(dir: &Path, interval: Duration) -> Result<Autosaver> {
let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display()));
fs::create_dir_all(dir).map_err(io)?;
@@ -361,20 +361,6 @@ impl Autosaver {
Ok(())
}
/// The user closed this design (saving it or choosing Don't Save): discard only its entry.
pub fn discard(mut self) {
// A write still running would recreate the entry, without a lock, after it is removed:
// the next launch would offer a design the user closed.
if let Some(w) = self.writing.take() {
let _ = w.done.recv();
}
for ext in ["json", solidcraft_io::DESIGN_EXT] {
let _ = fs::remove_file(file(&self.dir, &self.id, ext));
}
self.lock = None;
let _ = fs::remove_file(file(&self.dir, &self.id, "lock"));
}
/// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next
/// launch to offer. The lock is released either way.
pub fn close(mut self, s: &Session) {
+4 -26
View File
@@ -129,23 +129,6 @@ pub(crate) fn whole(message: impl std::fmt::Display) -> String {
format!("{WHOLE}{message}")
}
/// Marks a deserializer's message that carries its own path: an error from a flattened
/// `FeatureCommon`, which serde can't locate. The path (one of its field names) and the error
/// follow as JSON.
const LOCATED: char = '\u{2}';
/// `e`, an error deserializing a flattened `FeatureCommon`, as a deserializer's message that
/// keeps its path, for [`parse_issue`].
pub(crate) fn located(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
let located = serde_json::to_string(&(error_path(e), e.inner().to_string())).unwrap_or_default();
format!("{LOCATED}{located}")
}
fn error_path(e: &serde_path_to_error::Error<serde_json::Error>) -> String {
let path = e.path().to_string();
if path == "." { String::new() } else { path }
}
/// The top-level property names of a schema, following `allOf`/`anyOf`/`oneOf` and `$ref`s
/// into the root's `$defs`.
pub(crate) fn schema_keys(root: &Value) -> BTreeSet<String> {
@@ -224,15 +207,10 @@ fn nearest(key: &str, allowed: &BTreeSet<String>) -> Option<String> {
}
/// A deserialization failure as an issue, in the engine's style.
pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
let path = error_path(e);
fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) -> Issue {
let path = e.path().to_string();
let path = if path == "." { String::new() } else { path };
let inner = e.inner().to_string();
// An error from a flattened `FeatureCommon`: its field continues the path.
let (path, inner) = match inner.strip_prefix(LOCATED).and_then(|r| serde_json::from_str::<(String, String)>(r).ok()) {
Some((field, inner)) if path.is_empty() => (field, inner),
Some((field, inner)) => (format!("{path}.{field}"), inner),
None => (path, inner),
};
if let Some(complete) = inner.strip_prefix(WHOLE) {
// The handler's own message, which names the caller's value: in full up to a generous
// cap, since the contract also checks keys the handler ignores (`side` without `thin`).
@@ -240,7 +218,7 @@ pub(crate) fn parse_issue(e: &serde_path_to_error::Error<serde_json::Error>) ->
}
let message = if let Some(field) = inner.strip_prefix("missing field `").and_then(|r| r.split('`').next()) {
let at = if path.is_empty() { field.to_string() } else { format!("{path}.{field}") };
return Issue { path: at.clone(), message: truncate(&format!("`{at}` is required"), MAX_MESSAGE_LEN), suggestion: None };
return Issue { path: at.clone(), message: format!("`{at}` is required"), suggestion: None };
} else if path.is_empty() {
inner
} else if inner.starts_with("must ") {
@@ -1,4 +1,4 @@
//! Contracts and result types for the commands in `cmd/features.rs`.
//! The converted commands' parameter contracts and result types.
//!
//! Field doc comments are the schemas' descriptions. Keys the handlers read only in some
//! situations (`sketch` without `face`, the thin-wall keys with `thin: true`) are optional
@@ -7,6 +7,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::types::{
AngleExpr, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FromPoint, IdOrName, LengthExpr, OperationParam, Point3,
@@ -160,6 +161,39 @@ pub struct ExtrudeParams {
pub thickness2: Option<LengthExpr>,
}
/// Which command to describe.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DescribeParams {
/// The command's id, such as `solid.extrude`.
#[serde(deserialize_with = "command_id")]
pub command: String,
}
/// A command id, with the `engine.describe` handler's message for anything else.
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(serde::de::Error::custom("must be a command id")),
}
}
/// What `engine.describe` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DescribeResult {
pub id: String,
pub label: String,
/// The command's parameter documentation, as prose.
pub params: String,
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
pub input: Option<Value>,
/// The successful result's JSON Schema, or `null` when not described yet.
pub output: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
/// What a feature command returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct FeatureResult {
@@ -184,7 +218,3 @@ pub struct FeatureResult {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "features_tests.rs"]
mod tests;
-127
View File
@@ -1,127 +0,0 @@
use serde_json::{Value, json};
use super::{BoxParams, ExtrudeParams, FeatureResult};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(
input_schema_for::<BoxParams>(),
&["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
);
assert_keys(
input_schema_for::<ExtrudeParams>(),
&[
"body",
"body_name",
"body_names",
"convention",
"curves",
"direction",
"distance",
"distance2",
"extent",
"face",
"face_body",
"faces",
"from",
"name",
"operation",
"participants",
"profiles",
"side",
"sketch",
"start_offset",
"taper",
"targets",
"thickness",
"thickness2",
"thin",
"through_all",
"to",
"to_offset",
],
);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
("solid.box", json!({"length": true, "width": 5, "height": 5})),
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
("solid.extrude", json!({"distance": 5, "profiles": []})),
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
("solid.extrude", json!({"distance": 5, "faces": []})),
("solid.extrude", json!({"distance": 5, "faces": null})),
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
("solid.extrude", json!({"distance": 5, "thin": "true"})),
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
("solid.extrude", json!({"distance": 5, "from": "x"})),
("solid.extrude", json!({"distance": 5, "sketch": true})),
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
]);
}
#[test]
fn feature_result_serializes_like_the_old_reply() {
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
"removed_bodies": ["C"], "warning": warning, "ms": ms});
let new = FeatureResult {
feature: 7,
name: "Box1".into(),
bodies: vec!["A".into(), "B".into()],
created_bodies: vec!["B".into()],
changed_bodies: vec![],
removed_bodies: vec!["C".into()],
warning: warning.clone(),
ms,
param_warnings: vec![],
};
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
}
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
assert!(!required.contains(&"param_warnings"));
let mut s = Session::default();
sketch_rectangle(&mut s);
s.param_check = ParamCheck::Warn;
let replies = [
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
];
for r in replies {
let r = r.unwrap_or_else(|e| panic!("{e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
}
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
// schema leaves other keys open, so a new result key doesn't break clients.)
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
assert!(conforms(schema, schema, &ok));
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
let mut bad = ok.clone();
bad[k] = v;
assert!(!conforms(schema, schema, &bad), "{bad}");
}
let mut bad = ok.clone();
bad.as_object_mut().map(|o| o.remove("name"));
assert!(!conforms(schema, schema, &bad), "{bad}");
}
-42
View File
@@ -1,42 +0,0 @@
//! Contracts and result types for the commands in `cmd/inspect.rs`.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Which command to describe.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct DescribeParams {
/// The command's id, such as `solid.extrude`.
#[serde(deserialize_with = "command_id")]
pub command: String,
}
/// A command id, with the `engine.describe` handler's message for anything else.
fn command_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<String, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => Ok(s),
_ => Err(serde::de::Error::custom("must be a command id")),
}
}
/// What `engine.describe` returns.
#[derive(Debug, Serialize, JsonSchema)]
pub struct DescribeResult {
pub id: String,
pub label: String,
/// The command's parameter documentation, as prose.
pub params: String,
/// The parameters' JSON Schema, or `null` when the command has no contract yet.
pub input: Option<Value>,
/// The successful result's JSON Schema, or `null` when not described yet.
pub output: Option<Value>,
/// In the warn parameter-check mode: what the call's parameters got wrong.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_warnings: Vec<String>,
}
#[cfg(test)]
#[path = "inspect_tests.rs"]
mod tests;
-36
View File
@@ -1,36 +0,0 @@
use serde_json::json;
use super::DescribeParams;
use crate::Session;
use crate::schema::input_schema_for;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<DescribeParams>(), &["command"]);
}
#[test]
fn strict_keeps_todays_errors() {
assert_strict_keeps_todays_errors(&[
("engine.describe", json!({"command": 5})),
("engine.describe", json!({"command": null})),
("engine.describe", json!({"command": "solid.extrud"})),
]);
}
#[test]
fn describe_returns_both_schemas() {
let mut s = Session::default();
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d["id"], "solid.box");
assert!(d["input"]["properties"].get("length").is_some());
assert!(d["output"]["properties"].get("created_bodies").is_some());
// Its own output schema lists the warnings warn mode can add.
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
}
+8 -18
View File
@@ -22,22 +22,15 @@
//! more lenient (a value of the wrong type read as if the key were absent), the contract is the
//! stricter of the two; that difference is what warn and strict surface.
//!
//! Contracts live in one module per command file: `features.rs` for `cmd/features.rs`,
//! `inspect.rs` for `cmd/inspect.rs`, and so on, each with its own tests (`features_tests.rs`),
//! so conversions of different command files touch different files (apart from a new
//! module's line below).
//!
//! To convert a command: declare its parameters type in the module for its command file (and
//! its reply type, for an output schema), creating the module if it is the file's first
//! contract, from the shared types in [`types`] (the keys every feature takes come from
//! [`FeatureCommon`], which `solid.box` and `solid.extrude` predate); give its `CommandSpec`
//! `checked!(…)`, `.contract(…)` and `.output(…)` as `solid.extrude` does; and add it to that
//! module's `schemas_and_the_key_check_agree` and, for messages it already gives,
//! `strict_keeps_todays_errors`. Then run the whole test suite with the audit recorder (see
//! `record.rs`): a call that succeeds today but breaks the contract is either a form the
//! contract is missing or a caller's mistake.
//! To convert a command: declare its parameters type in `commands.rs` (and its reply type, for
//! an output schema), give its `CommandSpec` `checked!(…)`, `.contract(…)` and `.output(…)` as
//! `solid.extrude` does, and add it to `schemas_and_the_key_check_agree` and, for messages it
//! already gives, `strict_keeps_todays_errors`. Then run the whole test suite with the audit
//! recorder (see `record.rs`): a call that succeeds today but breaks the contract is either a
//! form the contract is missing or a caller's mistake.
mod check;
mod commands;
#[cfg_attr(target_arch = "wasm32", path = "record_wasm.rs")]
mod record;
mod types;
@@ -45,11 +38,8 @@ mod types;
#[cfg(test)]
mod tests;
// The contracts, by command file.
pub mod features;
pub mod inspect;
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked};
pub use commands::{BoxParams, DescribeParams, DescribeResult, ExtrudeParams, FeatureResult};
pub use types::*;
/// A command's `run` that checks the call against `$contract` before running `$handler`
+150 -260
View File
@@ -1,12 +1,7 @@
//! Tests of the shared types and the checking machinery, and helpers for the per-module tests
//! (`features_tests.rs`, …).
use schemars::Schema;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use super::check::schema_keys;
use super::features::{BoxParams, ExtrudeParams};
use super::*;
use crate::Session;
@@ -164,6 +159,54 @@ fn shape_problems_are_issues() {
assert!(messages(&c.check(&Value::Object(many))).iter().any(|m| m.starts_with("too many parameters")));
}
#[test]
fn schemas_and_the_key_check_agree() {
for (schema, fields) in [
(
input_schema_for::<BoxParams>(),
vec!["body_name", "body_names", "center", "corner", "height", "length", "name", "operation", "participants", "targets", "width"],
),
(
input_schema_for::<ExtrudeParams>(),
vec![
"body",
"body_name",
"body_names",
"convention",
"curves",
"direction",
"distance",
"distance2",
"extent",
"face",
"face_body",
"faces",
"from",
"name",
"operation",
"participants",
"profiles",
"side",
"sketch",
"start_offset",
"taper",
"targets",
"thickness",
"thickness2",
"thin",
"through_all",
"to",
"to_offset",
],
),
] {
let v = schema.as_value();
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
}
}
#[test]
fn present_null_is_refused_but_absent_is_fine() {
let base = json!({"length": 5, "width": 5, "height": 5});
@@ -185,7 +228,7 @@ fn present_null_is_refused_but_absent_is_fine() {
}
}
pub(super) fn sketch_rectangle(s: &mut Session) {
fn sketch_rectangle(s: &mut Session) {
for (id, p) in [
("sketch.create", json!({"plane": "XY"})),
("sketch.rectangle.center", json!({"center": [0, 0], "corner": [10, 10]})),
@@ -259,12 +302,32 @@ fn the_level_survives_a_new_design() {
assert_eq!(s.scratch().param_check, ParamCheck::Strict);
}
#[test]
fn feature_result_serializes_like_the_old_reply() {
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
let old = json!({"feature": 7, "name": "Box1", "bodies": ["A", "B"], "created_bodies": ["B"], "changed_bodies": [],
"removed_bodies": ["C"], "warning": warning, "ms": ms});
let new = FeatureResult {
feature: 7,
name: "Box1".into(),
bodies: vec!["A".into(), "B".into()],
created_bodies: vec!["B".into()],
changed_bodies: vec![],
removed_bodies: vec!["C".into()],
warning: warning.clone(),
ms,
param_warnings: vec![],
};
assert_eq!(serde_json::to_value(&new).ok(), Some(old));
}
}
/// Whether `v` conforms to `node` of the schema `root`, for the keywords these schemas use
/// apart from `pattern`: `type`, `properties`, `required`, `additionalProperties: false`,
/// `items`, `minItems`, `maxItems`, `uniqueItems`, `enum`, `minLength`, `maxLength`, `minimum`,
/// `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `anyOf`, `not` and `$ref`. (`integer`
/// here is what serde reads as one, so `1.0` is not.)
pub(super) fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
if let Some(name) = node.get("$ref").and_then(Value::as_str).and_then(|r| r.strip_prefix("#/$defs/")) {
return root.get("$defs").and_then(|d| d.get(name)).is_some_and(|t| conforms(root, t, v));
}
@@ -347,6 +410,38 @@ pub(super) fn conforms(root: &Value, node: &Value, v: &Value) -> bool {
true
}
#[test]
fn replies_match_the_output_schema() {
let schema = output_schema_for::<FeatureResult>();
let schema = schema.as_value();
let required: Vec<&str> = schema.get("required").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect();
assert!(!required.contains(&"param_warnings"));
let mut s = Session::default();
sketch_rectangle(&mut s);
s.param_check = ParamCheck::Warn;
let replies = [
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})),
s.execute("solid.extrude", &json!({"distance": 5, "operation": "join"})),
s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "colour": "red"})),
];
for r in replies {
let r = r.unwrap_or_else(|e| panic!("{e}"));
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
}
// The check itself catches a wrong type, a wrong item type and a missing key. (The output
// schema leaves other keys open, so a new result key doesn't break clients.)
let ok = json!({"feature": 1, "name": "Box1", "bodies": [], "created_bodies": [], "changed_bodies": [], "removed_bodies": [], "warning": null, "ms": null});
assert!(conforms(schema, schema, &ok));
for (k, v) in [("feature", json!("1")), ("bodies", json!([1])), ("warning", json!(5)), ("ms", json!("1"))] {
let mut bad = ok.clone();
bad[k] = v;
assert!(!conforms(schema, schema, &bad), "{bad}");
}
let mut bad = ok.clone();
bad.as_object_mut().map(|o| o.remove("name"));
assert!(!conforms(schema, schema, &bad), "{bad}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn the_recorder_keeps_a_bounded_number_of_signatures() {
@@ -364,6 +459,22 @@ fn the_recorder_keeps_a_bounded_number_of_signatures() {
assert_eq!(seen.len(), 3);
}
#[test]
fn describe_returns_both_schemas() {
let mut s = Session::default();
let d = s.execute("engine.describe", &json!({"command": "solid.box"})).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(d["id"], "solid.box");
assert!(d["input"]["properties"].get("length").is_some());
assert!(d["output"]["properties"].get("created_bodies").is_some());
// Its own output schema lists the warnings warn mode can add.
let own = s.execute("engine.describe", &json!({"command": "engine.describe"})).unwrap_or_else(|e| panic!("{e}"));
assert!(own["output"]["properties"].get("param_warnings").is_some(), "{own}");
let d = s.execute("engine.describe", &json!({"command": "solid.revolve"})).unwrap_or_else(|e| panic!("{e}"));
assert!(d["input"].is_null() && d["output"].is_null(), "unconverted commands have no schemas yet");
let e = s.execute("engine.describe", &json!({"command": "solid.extrud"})).err().map(|e| e.to_string());
assert!(e.as_deref().is_some_and(|e| e.contains("did you mean `solid.extrude`")), "{e:?}");
}
/// The profiles schema and the contract agree on every listed case; the schema is deliberately
/// narrower on non-text curve ids and loops past the outer one (and describes the app's
/// `whole_boundaries` selection only by its `type`).
@@ -443,29 +554,43 @@ fn recorder_lines_have_the_documented_shape() {
assert_eq!(super::record::enabled(), cfg!(feature = "param-audit") && set);
}
/// A contract's input schema lists exactly `fields` (sorted) as its keys, refuses other keys
/// and declares draft 2020-12. Each module's `schemas_and_the_key_check_agree` calls this for
/// its contracts, so their keys change only on purpose. (Whether the keys are the ones the
/// handler reads is what the audit run checks.)
pub(super) fn assert_keys(schema: Schema, fields: &[&str]) {
let v = schema.as_value();
assert_eq!(schema_keys(v).into_iter().collect::<Vec<_>>(), fields);
assert_eq!(v.get("additionalProperties"), Some(&json!(false)), "the schema refuses unknown keys too");
assert_eq!(v.get("$schema").and_then(Value::as_str), Some("https://json-schema.org/draft/2020-12/schema"));
}
/// A call that already fails gets the same error in strict as with the check off, apart from
/// the missing-key, missing-instead-of-wrong-type, non-object, profile-index, 10,000-item and
/// over-4-KB messages the report lists, and a list of picks where one finds nothing in the
/// design and a later one is malformed (the handler fails on the first, strict on the second).
/// Each module's `strict_keeps_todays_errors` calls this with its commands' cases; each runs
/// after `sketch_rectangle`.
pub(super) fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
/// over-4-KB messages the report lists.
#[test]
fn strict_keeps_todays_errors() {
let cases = [
("solid.box", json!({"length": 5, "width": 5, "height": 5, "operation": "bogus"})),
("solid.box", json!({"length": true, "width": 5, "height": 5})),
("solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [1, 2]})),
("solid.extrude", json!({"distance": 5, "operation": "bogus"})),
("solid.extrude", json!({"distance": 5, "direction": "sideways"})),
("solid.extrude", json!({"distance": 5, "direction": "SIDEWAYS"})),
("solid.extrude", json!({"distance": 5, "profiles": [{"point": [1]}]})),
("solid.extrude", json!({"distance": 5, "profiles": [["l1"], 7]})),
("solid.extrude", json!({"distance": 5, "profiles": []})),
("solid.extrude", json!({"distance": 5, "profiles": [true]})),
("solid.extrude", json!({"distance": 5, "faces": []})),
("solid.extrude", json!({"distance": 5, "faces": null})),
("solid.extrude", json!({"distance": 5, "faces": [[1, 2]]})),
("solid.extrude", json!({"distance": 5, "profiles": {"offset_boundaries": ["a", "a"]}})),
("solid.extrude", json!({"distance": 5, "thin": true, "thickness": 1, "side": "middle"})),
("solid.extrude", json!({"distance": 5, "thin": "true"})),
("solid.extrude", json!({"distance": 5, "to": [1, 2]})),
("solid.extrude", json!({"distance": 5, "from": "x"})),
("solid.extrude", json!({"distance": 5, "sketch": true})),
("solid.extrude", json!({"distance": 5, "convention": "per_side"})),
("solid.extrude", json!({"distance": " "})),
("solid.extrude", json!({"distance": 5, "operation": "x".repeat(1000)})),
("engine.describe", json!({"command": 5})),
("engine.describe", json!({"command": null})),
("engine.describe", json!({"command": "solid.extrud"})),
];
for (id, p) in cases {
let error = |level: ParamCheck| {
let mut s = Session { param_check: level, ..Session::default() };
sketch_rectangle(&mut s);
s.execute(id, p).err().map(|e| e.to_string())
s.execute(id, &p).err().map(|e| e.to_string())
};
let off = error(ParamCheck::Off);
assert!(off.is_some(), "{p} should fail today");
@@ -476,10 +601,7 @@ pub(super) fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
/// Every `default` in the input schemas is a value its own property schema allows.
#[test]
fn schema_defaults_are_valid() {
let contracts: Vec<_> = crate::cmd::command_specs().into_iter().filter_map(|c| c.contract).collect();
assert!(contracts.len() >= 3);
for c in contracts {
let root = c.input_schema();
for root in [input_schema_for::<BoxParams>(), input_schema_for::<ExtrudeParams>()] {
let root = root.as_value();
for (key, property) in root["properties"].as_object().into_iter().flatten() {
if let Some(d) = property.get("default") {
@@ -509,235 +631,3 @@ fn long_values_in_handler_messages_are_capped() {
let w = r["param_warnings"][0].as_str().unwrap_or_default();
assert!(w.starts_with("unknown side `xxx") && w.len() <= 4096 + "…".len(), "{} bytes", w.len());
}
/// The pick types accept what the pick readers accept. (Comparing a type with the helper it
/// calls only guards against the two drifting apart; the schema comparisons and the handler
/// tests below are the real checks.)
#[test]
fn face_and_edge_refs_match_the_pick_readers() {
let long = "f".repeat(513);
// 171 three-byte characters are 513 bytes, too long for the reader; the schema's maxLength
// counts characters, so it can't say this.
let wide = "面".repeat(171);
let face_schema = input_schema_for::<FaceRef>();
let face_schema = face_schema.as_value();
let edge_schema = input_schema_for::<EdgeRef>();
let edge_schema = edge_schema.as_value();
for v in [
json!([1, 2, 3]),
json!({"x": 1, "y": 2, "z": 3}),
json!({"point": [1, 2, 3]}),
json!({"point": {"x": 1, "y": 2, "z": 3}}),
json!({"x": "a", "point": [1, 2, 3]}),
json!("F3:1"),
json!(""),
json!(long),
json!([1, 2]),
json!([1e9, 0, 0]),
json!({"point": [1, 2]}),
json!({"body": "A", "index": 0}),
json!({"body": "A", "index": u64::MAX}),
json!({"body": "A", "index": -1}),
json!({"body": 5, "index": 0}),
json!({"body": "A"}),
json!({"point": "x", "body": "A", "index": 2}),
json!(null),
json!(5),
json!(true),
] {
let face = crate::cmd::face_ref_form(&v).is_ok();
assert_eq!(parse::<FaceRef>(v.clone()).is_some(), face, "{v}");
assert_eq!(conforms(face_schema, face_schema, &v), face, "face {v}");
let edge = crate::cmd::edge_ref_form(&v).is_ok();
assert_eq!(parse::<EdgeRef>(v.clone()).is_some(), edge, "{v}");
assert_eq!(conforms(edge_schema, edge_schema, &v), edge, "edge {v}");
}
// Where the schemas can't follow the readers: a name's cap is in bytes, and an index of
// `1.0`, which the reader refuses, is an integer to JSON Schema (`conforms` reads integers
// as serde does, so it agrees with the reader here; a standard validator would not).
assert!(parse::<FaceRef>(json!(wide)).is_none() && conforms(face_schema, face_schema, &json!(wide)));
assert!(parse::<EdgeRef>(json!({"body": "A", "index": 1.0})).is_none());
assert_eq!(edge_schema["anyOf"][2]["properties"]["index"]["type"], "integer");
assert_eq!(parse::<EdgeRef>(json!({"body": "A", "index": 4})), Some(EdgeRef::Index { body: "A".into(), index: 4 }));
assert_eq!(parse::<FaceRef>(json!({"point": [1, 2, 3]})), Some(FaceRef::Point(solidcraft_geom::Vec3::new(1.0, 2.0, 3.0))));
}
/// The pick lists take 1 to 1000 picks, as `face_points` and `edge_points` do, and so do their
/// schemas.
#[test]
fn pick_lists_match_the_list_readers() {
let faces = input_schema_for::<FaceRefs>();
let faces = faces.as_value();
let edges = input_schema_for::<EdgeRefs>();
let edges = edges.as_value();
let point = json!([1, 2, 3]);
for (v, accepted) in [
(json!([point]), true),
(json!(vec![point.clone(); 1000]), true),
(json!([point, {"point": [4, 5, 6]}]), true),
(json!([]), false),
(json!(vec![point.clone(); 1001]), false),
(json!([point, [1, 2]]), false),
(json!(point), false),
(json!(null), false),
(json!("F1"), false),
] {
assert_eq!(parse::<FaceRefs>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(faces, faces, &v), accepted, "faces {v}");
assert_eq!(parse::<EdgeRefs>(v.clone()).is_some(), accepted, "{v}");
assert_eq!(conforms(edges, edges, &v), accepted, "edges {v}");
}
// Names are faces, not edges; `{body, index}` is an edge, not a face.
let mixed = json!(["F1", {"body": "A", "index": 0}]);
assert!(parse::<FaceRefs>(json!(["F1"])).is_some() && parse::<EdgeRefs>(json!(["F1"])).is_none());
assert!(parse::<FaceRefs>(mixed.clone()).is_none() && !conforms(faces, faces, &mixed));
assert_eq!(parse::<EdgeRefs>(json!([{"body": "A", "index": 0}])).map(|e| e.0.len()), Some(1));
}
#[test]
fn sketch_refs_match_sketch_id() {
let schema = input_schema_for::<SketchRef>();
let schema = schema.as_value();
for v in [json!(3), json!(2.5), json!(-1), json!("Profile"), json!(""), json!(null), json!(true), json!([1]), json!({"id": 1})] {
let sketch = crate::cmd::sketch_key(Some(&v));
assert_eq!(parse::<SketchRef>(v.clone()).map(|s| s.0), sketch, "{v}");
assert_eq!(conforms(schema, schema, &v), sketch.is_some(), "{v}");
}
}
/// A contract built from the shared types, as a converted feature command's would be: the pick
/// keys refuse `null`, as their readers do.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[schemars(deny_unknown_fields)]
#[allow(dead_code)]
struct Probe {
radius: LengthExpr,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "FaceRefs")]
faces: Option<FaceRefs>,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "EdgeRefs")]
edges: Option<EdgeRefs>,
#[serde(default, deserialize_with = "present")]
#[schemars(with = "SketchRef")]
path_sketch: Option<SketchRef>,
#[serde(flatten, deserialize_with = "feature_common")]
common: FeatureCommon,
}
#[test]
fn feature_common_flattens_into_a_contract() {
let c = TypedContract::<Probe>::new();
assert_keys(input_schema_for::<Probe>(), &["body_name", "body_names", "edges", "faces", "name", "participants", "path_sketch", "radius"]);
let ok = c.check(&json!({"radius": 1, "name": "Round", "body_name": "B", "body_names": ["B", "C"], "participants": "A"}));
assert!(ok.is_empty(), "{ok:?}");
assert!(c.check(&json!({"radius": 1, "name": null, "participants": null})).is_empty(), "null is absent, as for `str_`");
let parsed: Option<Probe> = parse(json!({"radius": 1, "name": "Round", "participants": ["A", "B"]}));
assert_eq!(parsed.map(|p| p.common.participants), Some(Some(StringList(vec!["A".into(), "B".into()]))));
// Messages and paths name the key, as for the parent's own fields.
for (p, path, message) in [
(json!({"radius": 1, "name": 5}), "name", "`name` must be text"),
(json!({"radius": 1, "body_name": [1]}), "body_name", "`body_name` must be text"),
(json!({"radius": 1, "participants": 5}), "participants", "`participants` must be a name or a list of names"),
(json!({"radius": 1, "body_names": vec!["x"; 10_001]}), "body_names", "`body_names` must have at most 10000 items"),
(json!({"radius": 1, "bodyname": "x"}), "bodyname", "unknown key `bodyname`; did you mean `body_name`?"),
(json!({"radius": 1, "faces": null}), "faces", "`faces` must be a list of points or face names"),
(json!({"radius": 1, "edges": null}), "edges", "`edges` must be a list"),
(json!({"radius": 1, "path_sketch": null}), "path_sketch", "`path_sketch` must be a sketch id or name"),
(json!({"name": "x"}), "radius", "`radius` is required"),
// The flattened keys are read after the parent's own, whatever their order.
(json!({"name": 5}), "radius", "`radius` is required"),
(json!({"body_name": 5, "radius": true}), "radius", "`radius` must be a number or an expression"),
] {
let issues = c.check(&p);
assert_eq!(messages(&issues), vec![message], "{p}");
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
}
}
/// A flattened `FeatureCommon`'s errors are located below wherever its parent is.
#[test]
fn feature_common_errors_keep_their_place() {
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
struct Outer {
options: Probe,
#[serde(default)]
list: Vec<Probe>,
}
let c = TypedContract::<Outer>::new();
let probe = json!({"radius": 1});
for (p, path, message) in [
(json!({"options": {"radius": 1, "name": 5}}), "options.name", "`options.name` must be text"),
(
json!({"options": probe, "list": [probe, {"radius": 1, "participants": 5}]}),
"list[1].participants",
"`list[1].participants` must be a name or a list of names",
),
(json!({"options": probe, "list": [{"radius": 1, "faces": []}]}), "list[0].faces", "select 1…1000 faces"),
] {
let issues = c.check(&p);
assert_eq!(messages(&issues), vec![message], "{p}");
assert_eq!(issues.first().map(|i| i.path.as_str()), Some(path), "{p}");
}
}
/// The pick types refuse what the handlers refuse, with the handlers' messages.
#[test]
fn shared_types_keep_the_handlers_messages() {
let c = TypedContract::<Probe>::new();
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
sketch_rectangle(&mut s);
for (id, p, probe) in [
("solid.shell", json!({"thickness": 1, "faces": 5}), json!({"faces": 5})),
("solid.shell", json!({"thickness": 1, "faces": null}), json!({"faces": null})),
("solid.shell", json!({"thickness": 1, "faces": []}), json!({"faces": []})),
("solid.shell", json!({"thickness": 1, "faces": [true]}), json!({"faces": [true]})),
("solid.shell", json!({"thickness": 1, "faces": [""]}), json!({"faces": [""]})),
("solid.fillet", json!({"radius": 1, "edges": {}}), json!({"edges": {}})),
("solid.fillet", json!({"radius": 1, "edges": vec![[0, 0, 0]; 1001]}), json!({"edges": vec![[0, 0, 0]; 1001]})),
("solid.fillet", json!({"radius": 1, "edges": [[1, 2]]}), json!({"edges": [[1, 2]]})),
("solid.fillet", json!({"radius": 1, "edges": [{"body": "Body1", "index": "0"}]}), json!({"edges": [{"body": "Body1", "index": "0"}]})),
("solid.sweep", json!({"path_sketch": true, "path": ["l1"]}), json!({"path_sketch": true})),
("solid.sweep", json!({"path_sketch": null, "path": ["l1"]}), json!({"path_sketch": null})),
] {
let mut probe = probe;
probe["radius"] = json!(1);
let issue = messages(&c.check(&probe)).into_iter().next().unwrap_or_default();
let today = s.execute(id, &p).err().map(|e| e.to_string());
assert_eq!(today, Some(crate::params::bad(id, issue).to_string()), "{id} {p}");
}
}
/// The lookups that follow the shape checks the pick types share with the handlers: picks
/// by `{body, index}` and sketches by name still find the design, and still fail in order, one
/// pick at a time.
#[test]
fn picks_are_still_looked_up_one_at_a_time() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap_or_else(|e| panic!("{e}"));
let error = |s: &mut Session, id: &str, p: Value| s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
let fillet = |edges: Value| json!({"radius": 1, "edges": edges});
assert_eq!(
error(&mut s, "solid.fillet", fillet(json!([{"body": "Nope", "index": 0}, [1, 2]]))),
"invalid parameters for `solid.fillet`: no body `Nope`"
);
assert_eq!(
error(&mut s, "solid.fillet", fillet(json!([{"body": "Body1", "index": 999}]))),
"invalid parameters for `solid.fillet`: Body1 has no edge 999"
);
let far = error(&mut s, "solid.fillet", fillet(json!([[100, 100, 100], [1, 2]])));
assert!(far.starts_with("invalid parameters for `solid.fillet`: no edge at [100, 100, 100]"), "{far}");
let unknown = error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace"]}));
assert!(!unknown.is_empty());
assert_eq!(error(&mut s, "solid.shell", json!({"thickness": 1, "faces": ["NoSuchFace", true]})), unknown, "the name is looked up first");
sketch_rectangle(&mut s);
assert_eq!(
error(&mut s, "solid.sweep", json!({"path_sketch": "Nope", "path": ["l1"]})),
"invalid parameters for `solid.sweep`: no sketch `Nope`"
);
assert_eq!(error(&mut s, "solid.extrude", json!({"distance": 5, "sketch": "Nope"})), "invalid parameters for `solid.extrude`: no sketch `Nope`");
let r = s.execute("solid.fillet", &fillet(json!([{"body": "Body1", "index": 0}]))).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(r["changed_bodies"], json!(["Body1"]), "{r}");
}
-198
View File
@@ -583,201 +583,3 @@ impl JsonSchema for ProfilesParam {
})
}
}
/// A face given to a command, as `face::face_ref` reads it: a point on the face (`[x, y, z]`,
/// `{x, y, z}` or `{point}`) or its name as `model.faces` lists it (1 to 512 bytes; the
/// schema's `maxLength` counts characters, so it can't say bytes). Whether it finds a face is
/// checked by the handler, which has the design. (Not `solidcraft_doc::FacePick`, the stored
/// pick.)
#[derive(Clone, Debug, PartialEq)]
pub enum FaceRef {
Point(Vec3),
Name(String),
}
impl<'de> Deserialize<'de> for FaceRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match crate::cmd::face_ref_form(&Value::deserialize(d)?) {
Ok(crate::cmd::FaceRefForm::Point(p)) => Ok(FaceRef::Point(p)),
Ok(crate::cmd::FaceRefForm::Name(n)) => Ok(FaceRef::Name(n.to_string())),
Err(why) => Err(D::Error::custom(whole(why))),
}
}
}
impl JsonSchema for FaceRef {
fn schema_name() -> Cow<'static, str> {
"FaceRef".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "A face: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or its name as model.faces lists it (at most 512 bytes of UTF-8).",
"anyOf": [
point,
{"type": "object", "properties": {"point": point}, "required": ["point"]},
{"type": "string", "minLength": 1, "maxLength": 512}
]
})
}
}
/// An edge given to a fillet or chamfer, as `features::edge_points` reads it: a point on the
/// edge (`[x, y, z]`, `{x, y, z}` or `{point}`), else `{body, index}` in the body's edge order.
/// The index is whole-number JSON, as `Value::as_u64` reads it: JSON Schema also counts `1.0`
/// as an integer, which the reader refuses. Whether it finds an edge is checked by the handler,
/// which has the design. (Not `solidcraft_doc::EdgeRef`, the stored edge position.)
#[derive(Clone, Debug, PartialEq)]
pub enum EdgeRef {
Point(Vec3),
Index { body: String, index: u64 },
}
impl<'de> Deserialize<'de> for EdgeRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match crate::cmd::edge_ref_form(&Value::deserialize(d)?) {
Ok(crate::cmd::EdgeRefForm::Point(p)) => Ok(EdgeRef::Point(p)),
Ok(crate::cmd::EdgeRefForm::Index { body, index }) => Ok(EdgeRef::Index { body: body.to_string(), index }),
Err(why) => Err(D::Error::custom(whole(why))),
}
}
}
impl JsonSchema for EdgeRef {
fn schema_name() -> Cow<'static, str> {
"EdgeRef".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let point = generator.subschema_for::<Point3>();
json_schema!({
"description": "An edge: a point in mm on it ([x, y, z], {\"x\", \"y\", \"z\"} or {\"point\": [x, y, z]}), or {\"body\", \"index\"} in the body's edge order (an index written without a fraction).",
"anyOf": [
point,
{"type": "object", "properties": {"point": point}, "required": ["point"]},
{
"type": "object",
"properties": {"body": {"type": "string"}, "index": {"type": "integer", "minimum": 0, "maximum": u64::MAX}},
"required": ["body", "index"]
}
]
})
}
}
/// A list of 1 to 1000 picks, with the handler's messages for the list itself. Like the handler,
/// it refuses `null`: an optional field of this type takes `present`.
///
/// The handler looks each pick up in the design before reading the next, so a call with a pick
/// that finds nothing and a later malformed one fails today on the first and in strict on the
/// second.
macro_rules! pick_list {
($(#[$doc:meta])* $name:ident, $item:ty, $not_list:literal, $count:literal, $description:literal) => {
$(#[$doc])*
#[derive(Clone, Debug, PartialEq)]
pub struct $name(pub Vec<$item>);
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = Value::deserialize(d)?;
let items = v.as_array().ok_or_else(|| D::Error::custom(whole($not_list)))?;
if items.is_empty() || items.len() > 1000 {
return Err(D::Error::custom(whole($count)));
}
items.iter().map(|item| <$item>::deserialize(item).map_err(D::Error::custom)).collect::<Result<_, _>>().map($name)
}
}
impl JsonSchema for $name {
fn schema_name() -> Cow<'static, str> {
stringify!($name).into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
let item = generator.subschema_for::<$item>();
json_schema!({"description": $description, "type": "array", "items": item, "minItems": 1, "maxItems": 1000})
}
}
};
}
pick_list!(
/// The `faces` of `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face` and
/// `face.delete`, as `features::face_points` reads them. (Extrude's `faces` is a
/// [`FacesParam`].)
FaceRefs,
FaceRef,
"`faces` must be a list of points or face names",
"select 1…1000 faces",
"The faces, 1 to 1000."
);
pick_list!(
/// The `edges` of `solid.fillet` and `solid.chamfer`, as `features::edge_points` reads them.
EdgeRefs,
EdgeRef,
"`edges` must be a list",
"select 1…1000 edges",
"The edges, 1 to 1000."
);
/// A sketch referred to by its feature id (a number) or its name, as `features::sketch_id` reads
/// it, with its message for a top-level key (`path_sketch`, `solid.split_face`'s `sketch`…);
/// for a nested one (a section's `sketch`) the message names the key as `sections[0].sketch`
/// where the handler says `sections[].sketch`. The `sketch` that `features::feature_sketch`
/// reads (extrude's, revolve's…) has another message and is an [`IdOrName`]. Where the reader
/// refuses `null`, an optional field of this type takes `present`. Whether a sketch has that id
/// or name is checked by the handler.
#[derive(Clone, Debug, PartialEq)]
pub struct SketchRef(pub String);
impl<'de> Deserialize<'de> for SketchRef {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
crate::cmd::sketch_key(Some(&Value::deserialize(d)?)).map(SketchRef).ok_or_else(|| D::Error::custom("must be a sketch id or name"))
}
}
impl JsonSchema for SketchRef {
fn schema_name() -> Cow<'static, str> {
"SketchRef".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({"description": "A sketch's feature id (a number) or its name.", "type": ["number", "string"]})
}
}
/// The keys `features::add_feature` reads for every feature command, whatever else it takes:
/// the feature's name, the new bodies' names and the bodies taking part. A feature command's
/// parameters include it with
///
/// ```ignore
/// #[serde(flatten, deserialize_with = "feature_common")]
/// pub common: FeatureCommon,
/// ```
///
/// Two keys `add_feature` also reads aren't here. `targets` it reads only for primitives; the
/// other commands that read it are the ones that combine bodies. `operation` it reads for every
/// feature (a cut where the active component has no bodies takes part in all of them), but only
/// the commands that combine bodies take one; those declare it with their own type
/// ([`OperationParam`] or [`ExtrudeOperation`]), and for the rest it is a caller's mistake.
#[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)]
pub struct FeatureCommon {
/// The feature's name in the timeline.
#[serde(default)]
pub name: Option<String>,
/// The new body's name.
#[serde(default)]
pub body_name: Option<String>,
/// Names for the new bodies, in order.
#[serde(default)]
pub body_names: Option<StringList>,
/// The bodies taking part; overrides the default scope.
#[serde(default)]
pub participants: Option<StringList>,
}
/// Deserializes a flattened [`FeatureCommon`] from the keys the parent doesn't take, and reports
/// a problem as if its fields were the parent's own, with the key in the message and in the
/// issue's path (serde's flatten otherwise loses the key). serde reads a flattened field after
/// all the parent's own, so its problems come after theirs, including a missing key.
pub fn feature_common<'de, D: Deserializer<'de>>(d: D) -> Result<FeatureCommon, D::Error> {
let v = Value::deserialize(d)?;
serde_path_to_error::deserialize(&v).map_err(|e| D::Error::custom(super::check::located(&e)))
}
-51
View File
@@ -1785,23 +1785,6 @@ fn checkpoint_restores_with_a_full_undo_history() {
assert!(s.execute("edit.restore_checkpoint", &json!({"checkpoint": id})).is_err());
}
/// Regression (#3242): `file.new` replaced the session and its checkpoints with it, so a
/// batch that failed after `file.new` could not go back to the design it began in.
#[test]
fn a_checkpoint_survives_file_new() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 1.0, "width": 1.0, "height": 1.0}));
let id = run(&mut s, "edit.checkpoint", json!({}))["checkpoint"].as_u64().unwrap();
run(&mut s, "file.new", json!({"name": "Replacement"}));
run(&mut s, "solid.box", json!({"length": 2.0, "width": 2.0, "height": 2.0}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(8.0));
run(&mut s, "edit.restore_checkpoint", json!({"checkpoint": id}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(1.0));
// The undo history is the restored design's own: one step back is the empty design.
run(&mut s, "edit.undo", json!({}));
assert_eq!(run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64(), Some(0.0));
}
#[test]
fn components_occurrences_and_world_placement() {
let mut s = Session::default();
@@ -3032,40 +3015,6 @@ fn shell_directions_and_tangent_chain() {
assert!(s.execute("solid.shell", &json!({"faces": [[20, 15, 20]], "thickness": 2, "direction": "sideways"})).is_err());
}
/// #209: selecting a tangent chain must explain why no shell can remain.
#[test]
fn shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 60}));
run(
&mut s,
"solid.fillet",
json!({
"edges": [[0,0,30], [40,0,30], [40,30,30], [0,30,30],
[20,0,60], [20,30,60], [0,15,60], [40,15,60],
[20,0,0], [20,30,0], [0,15,0], [40,15,0]],
"radius": 1.5
}),
);
let before = s.doc.to_json();
for direction in ["inside", "outside", "both"] {
let error = s
.execute(
"solid.shell",
&json!({
"faces": [[20,0,30]], "thickness": 2, "tangent_chain": true, "direction": direction
}),
)
.unwrap_err()
.to_string();
assert!(error.contains("Tangent Chain") && error.contains("every face") && error.contains("turn off"), "{direction}: {error}");
assert_eq!(s.doc.to_json(), before, "a refused shell must preserve the design");
}
run(&mut s, "solid.shell", json!({"faces": [[20,0,30]], "thickness": 2, "tangent_chain": false}));
assert!((volume(&mut s) - 15_305.28).abs() < 0.1);
assert_eq!(s.model.state().bodies.len(), 1);
}
/// Hole To Object (a face it meets, or a point) and holes at every point of a sketch.
#[test]
fn hole_to_object_and_at_sketch_points() {
+2 -29
View File
@@ -170,8 +170,8 @@ fn wrong_up_and_names_are_refused() {
assert_eq!(kinds(&s), ["BoxFeature", "BaseFeature"]);
}
/// Explicit naming choices select the solid, part or occurrence; automatic naming keeps
/// meaningful solid names.
/// `names` says what the bodies are called: the solid's own name (the default), the part's, or
/// the assembly occurrence's. Repeated names are numbered as ever.
#[test]
fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
let path = frame_file("names");
@@ -185,7 +185,6 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
// The post is placed twice: one component, two occurrences, its body built once (#1386).
let solids = ["Cut-Extrude3", "Boss-Extrude1"];
assert_eq!(named(json!({"path": path})), solids);
assert_eq!(named(json!({"path": path, "names": "auto"})), solids);
assert_eq!(named(json!({"path": path, "names": "solid"})), solids);
assert_eq!(named(json!({"path": path, "names": "product"})), ["Plate", "Post"]);
assert_eq!(named(json!({"path": path, "names": "Instance"})), ["Plate-1", "Post-1"]);
@@ -229,29 +228,3 @@ fn a_step_file_can_be_inserted_at_a_matrix() {
assert!(e.contains("not rigid"), "{e}");
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
}
/// Generated body labels become part names, and the saved assembly retains its components
/// and repeated placements without needing the source file.
#[test]
fn automatic_part_names_and_assembly_survive_reopening() {
let path = frame_file("automatic");
let text = std::fs::read_to_string(&path).unwrap().replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
std::fs::write(&path, text).unwrap();
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", json!({"path": path}));
assert_eq!(body_names(&s), ["Plate", "Post"]);
let top = r["component"].as_u64().unwrap();
let parts: Vec<_> = s.doc.components.iter().filter(|c| c.parent == top).collect();
assert_eq!(parts.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["Plate", "Post"]);
assert_eq!(s.doc.occurrences.iter().filter(|o| o.component == parts[1].id && o.parent == top).count(), 2);
let design = std::path::Path::new(&path).with_file_name("automatic.solidcraft");
run(&mut s, "file.save_as", json!({"path": design.to_string_lossy()}));
std::fs::remove_file(&path).unwrap();
let mut again = Session::default();
run(&mut again, "doc.open", json!({"path": design.to_string_lossy()}));
assert_eq!(body_names(&again), ["Plate", "Post"]);
assert_eq!(again.doc.components, s.doc.components);
assert_eq!(again.doc.occurrences, s.doc.occurrences);
assert!(near(extent(&again).0, [0.0, 0.0, 0.0]) && near(extent(&again).1, [40.0, 34.0, 20.0]));
assert!((total_volume(&mut again) - (40.0 * 4.0 * 20.0 + 2.0 * 4.0 * 30.0 * 4.0)).abs() < 1e-6);
}
+9 -86
View File
@@ -4,7 +4,6 @@
use std::f64::consts::PI;
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
@@ -93,89 +92,6 @@ fn modelled_full_length_fine_threads_on_large_shafts() {
}
}
/// A modelled thread on a shaft thicker than it turns the shaft down to the major diameter
/// over the threaded length, then cuts the thread: an M6 over the top 8 mm of an 8 mm shaft
/// leaves a 6 mm threaded section on an 8 mm stub, its turned face named for later features.
/// A shaft of the thread's own size is cut as it is.
#[test]
fn a_shaft_thicker_than_a_modelled_thread_is_turned_down_to_it() {
let (p, len) = (1.0, 8.0);
let root = 3.0 - 5.0 * p * 3f64.sqrt() / 16.0;
let groove = per_turn(root, 3.0, |r| p / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * len / p;
for (shaft, turned) in [(8.0, true), (6.0, false)] {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": shaft / 2.0, "height": 12, "body_name": "Shaft"}));
run(&mut s, "solid.thread", json!({"face": [shaft / 2.0, 0, 11], "designation": "M6", "length": len, "modeled": true}));
let ring = PI * (shaft * shaft / 4.0 - 9.0) * len;
let want = PI * shaft * shaft / 4.0 * 12.0 - ring - groove;
let got = volume(&mut s, "Shaft");
assert!((got - want).abs() < groove * 0.03, "M6 on {shaft} mm: {got} mm³, want {want}");
let st = s.model.state();
let Some(b) = st.body("Shaft") else { panic!("no shaft") };
assert!(b.body.validity().is_empty(), "{shaft} mm: {:?}", b.body.validity());
// Nothing past the major diameter over the threaded length; the stub keeps its size.
let round = |z: f64, r: f64| {
(0..24).filter(|i| b.body.contains(Vec3::new(r * (PI * *i as f64 / 12.0).cos(), r * (PI * *i as f64 / 12.0).sin(), z))).count()
};
assert_eq!(round(8.0, 3.05), 0, "{shaft} mm: the threaded part is turned to 6 mm");
assert_eq!(round(2.0, shaft / 2.0 - 0.05), 24, "{shaft} mm: the stub is whole");
let names = solidcraft_doc::naming::face_names(b);
let fid = s.doc.features.last().map(|f| f.id).unwrap_or(0);
let named = |role: &str| names.iter().any(|n| n.starts_with(&format!("F{fid}:{role}")));
assert_eq!(named("turned"), turned, "{shaft} mm: {names:?}");
assert_eq!(named("shoulder:"), turned, "{shaft} mm: {names:?}");
}
// A 9 mm shaft is close enough to M8 to be offered it; it was refused as too thick (#2688).
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
run(&mut s, "solid.thread", json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true}));
let root = 4.0 - 5.0 * 1.25 * 3f64.sqrt() / 16.0;
let groove = per_turn(root, 4.0, |r| 1.25 / 4.0 + 2.0 * (r - root) * (PI / 6.0).tan()) * 10.0 / 1.25;
let (want, got) = (PI * 16.0 * 10.0 - groove, volume(&mut s, "Shaft"));
assert!((got - want).abs() < groove * 0.03, "M8 on 9 mm: {got} mm³, want {want}");
}
/// Issue 2705: an M14 over the top 10 mm of a 14.6 mm boss on a plate. Turned to 14 mm first:
/// 1600 + π 7.3² 15 − π (7.3² − 7²) 10 − 5 turns of 50.189 mm³ (tests/kernel-cases/threads/2705).
#[test]
fn an_m14_thread_on_a_14_6_mm_boss_turns_it_to_14_mm() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-10, -10, 0], "length": 20, "width": 20, "height": 4, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 7.3, "height": 15, "base": [0, 0, 4], "operation": "join", "targets": ["Part"]}));
run(&mut s, "solid.thread", json!({"face": [7.3, 0, 17], "designation": "M14", "length": 10, "modeled": true}));
let got = volume(&mut s, "Part");
assert!((got - 3725.5127).abs() < 3725.5127 * 0.01, "{got} mm³");
}
/// A shaft thinner than the thread's minor diameter is still refused, and so is a cosmetic
/// thread on a shaft thicker than it (a note changes nothing to turn). A shaft the part goes on
/// round (standing against a wall) is not turned into the wall.
#[test]
fn a_thin_shaft_or_one_the_part_surrounds_is_refused() {
let refused = |s: &mut Session, body: &str, p: Value| {
let before = volume(s, body);
let e = s.execute("solid.thread", &p).err().map(|e| e.to_string()).unwrap_or_default();
assert!((volume(s, body) - before).abs() < 1e-9, "{p}: {e}");
e
};
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 2.4, "height": 10, "body_name": "Shaft"}));
let e = refused(&mut s, "Shaft", json!({"face": [2.4, 0, 5], "designation": "M6", "modeled": true}));
assert!(e.contains("does not fit a 4.80 mm shaft") || e.contains("too thin"), "{e}");
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "body_name": "Shaft"}));
let e = refused(&mut s, "Shaft", json!({"face": [4, 0, 5], "designation": "M6"}));
assert!(e.contains("an M6 thread does not fit a 8.00 mm shaft"), "{e}");
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [2, -10, 0], "length": 10, "width": 20, "height": 10, "body_name": "Part"}));
run(&mut s, "solid.cylinder", json!({"radius": 4, "height": 10, "operation": "join", "targets": ["Part"]}));
let e = refused(&mut s, "Part", json!({"face": [-4, 0, 5], "designation": "M6", "modeled": true}));
assert!(e.contains("this 8 mm shaft is too thick for a modelled M6 thread") && e.contains("part around it"), "{e}");
}
#[test]
fn non_finite_thread_designations_are_refused_without_changing_the_design() {
let mut s = Session::default();
@@ -258,9 +174,9 @@ fn a_modelled_thread_that_would_cut_nothing_is_refused() {
/// Issue 2688: an M8 modelled thread in a 6 mm hole (narrower than its 6.65 mm minor diameter)
/// was refused as "the face is too far from the thread's diameter". It says what is wrong and
/// what to drill, as a hole that is too wide does. (A shaft too thick is turned down.)
/// what to drill, as a hole that is too wide does; and the same for a shaft that is too thick.
#[test]
fn a_hole_too_narrow_says_so_and_what_fits() {
fn a_hole_too_narrow_or_a_shaft_too_thick_says_which_and_what_fits() {
let mut s = hex_nut(6.0);
let before = volume(&mut s, "Nut");
let e = s.execute("solid.thread", &json!({"face": [3.0, 0, 2], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
@@ -268,6 +184,13 @@ fn a_hole_too_narrow_says_so_and_what_fits() {
assert!(e.contains("this 6 mm hole is too narrow for a modelled M8 thread") && e.contains("drill 6.75 mm for M8"), "{e}");
assert!(!e.contains("too far"), "{e}");
assert!((volume(&mut s, "Nut") - before).abs() < 1e-9);
let mut s = Session::default();
// 9 mm is close enough to M8 to be offered it, too thick to cut it.
run(&mut s, "solid.cylinder", json!({"radius": 4.5, "height": 10, "body_name": "Shaft"}));
let e = s.execute("solid.thread", &json!({"face": [4.5, 0, 5], "designation": "M8", "modeled": true})).err().map(|e| e.to_string());
let e = e.unwrap_or_default();
assert!(e.contains("this 9 mm shaft is too thick for a modelled M8 thread") && e.contains("make the shaft 8 mm for M8"), "{e}");
}
/// The limit is a quarter of the basic depth 5H/8: a hole up to 4.78 mm for M5 (4.86 mm for
+1 -13
View File
@@ -8,9 +8,6 @@ pub mod envelope;
pub const VERSION: u32 = 3;
pub const MAX_ITEMS: usize = 1_000_000;
const MAX_DEPTH: usize = 16;
/// Most control points along one direction of a spline (a helical thread flank written as one
/// spline has thousands along the helix); the item budget bounds a surface's grid as a whole.
pub const MAX_SPLINE_CONTROLS: usize = 8192;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -198,7 +195,7 @@ fn knots(values: &[f64], count: usize, budget: &mut Budget) -> Result<()> {
budget.add(values.len())?;
numbers(values.iter().copied())?;
let degree = values.len().checked_sub(count.saturating_add(1)).ok_or(Invalid("spline degree"))?;
require((2..=MAX_SPLINE_CONTROLS).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require((2..=4096).contains(&count) && (1..=16).contains(&degree) && degree < count, "spline degree or size")?;
require(values.windows(2).all(|w| w.first() <= w.get(1)), "unordered knots")?;
require(values.get(degree) < values.get(count), "empty spline domain")?;
// No knot may have multiplicity greater than degree + 1.
@@ -485,15 +482,6 @@ mod tests {
assert!(surface(&p, 0, &mut Budget(0)).is_err());
assert!(curve(&Curve::Line { start: [0.; 3], end: [1.; 3] }, MAX_DEPTH + 1, &mut Budget(0)).is_err());
assert!(Budget(MAX_ITEMS).add(1).is_err());
// Control points along one direction: up to the cap, not one more (a clamped cubic).
let clamped = |count: usize| {
let mut k = vec![0.0; 4];
k.extend((1..count - 3).map(|i| i as f64));
k.extend([(count - 3) as f64; 4]);
k
};
assert!(knots(&clamped(MAX_SPLINE_CONTROLS), MAX_SPLINE_CONTROLS, &mut Budget(0)).is_ok());
assert!(knots(&clamped(MAX_SPLINE_CONTROLS + 1), MAX_SPLINE_CONTROLS + 1, &mut Budget(0)).is_err());
}
#[test]
+6 -33
View File
@@ -36,10 +36,7 @@ pub enum EvalSurface {
/// `origin + u·du + v·dv`.
Plane { origin: Vec3, du: Vec3, dv: Vec3 },
/// A rational tensor B-spline; control point `(i, j)` is `ctrl[j·count[0] + i]`.
/// `closed[d]`: the surface joins itself along direction `d` (a B-spline tube's way round).
/// Such a direction is periodic: a parameter outside the knot range is moved by whole
/// ranges back into it, so a face whose boundary runs across the join still evaluates.
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]>, closed: [bool; 2] },
Nurbs { degree: [usize; 2], knots: [Vec<f64>; 2], count: [usize; 2], ctrl: Vec<[f64; 4]> },
/// `profile(u)` turned by the angle `v` (right-handed) about the axis through `origin`
/// along the unit `axis`, then mapped by the affine `transform` (row-major 3×4); with
/// `swap` the parameters are exchanged first (the surface is evaluated at `(v, u)`).
@@ -131,7 +128,7 @@ impl TessPlan {
.iter()
.map(|s| match s {
EvalSurface::Plane { origin, du, dv } => EvalSurface::Plane { origin: point(*origin), du: vector(*du), dv: vector(*dv) },
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => EvalSurface::Nurbs {
EvalSurface::Nurbs { degree, knots, count, ctrl } => EvalSurface::Nurbs {
degree: *degree,
knots: knots.clone(),
count: *count,
@@ -143,9 +140,6 @@ impl TessPlan {
[q.x, q.y, q.z, c[3]]
})
.collect(),
// Which directions the surface joins itself along is a property of its
// parameters: moving the surface does not change it.
closed: *closed,
},
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
// `m` (as rows) after the surface's own affine map.
@@ -241,20 +235,7 @@ impl EvalSurface {
}
match self {
EvalSurface::Plane { origin, du, dv } => Some((*origin + *du * u + *dv * v, *du, *dv)),
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
// The knot range of direction `d`: the parameters the surface is defined over.
let range = |d: usize| -> Option<(f64, f64)> {
let k = knots.get(d)?;
Some((*k.get(*degree.get(d)?)?, *k.get(*count.get(d)?)?))
};
// A direction the surface joins itself along repeats, so a parameter outside the
// range is moved by whole ranges back into it. Any other direction is left alone.
let wrap = |d: usize, t: f64| match range(d) {
Some((a, b)) if closed.get(d).copied().unwrap_or(false) && b > a && !(a..=b).contains(&t) => a + (t - a).rem_euclid(b - a),
_ => t,
};
nurbs_derivs(*degree, knots, *count, ctrl, wrap(0, u), wrap(1, v))
}
EvalSurface::Nurbs { degree, knots, count, ctrl } => nurbs_derivs(*degree, knots, *count, ctrl, u, v),
EvalSurface::Revolved { profile, origin, axis, transform, swap } => {
let (s, a) = if *swap { (v, u) } else { (u, v) };
let (c, dc) = profile.eval(s)?;
@@ -409,7 +390,6 @@ mod tests {
knots: [vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [3, 2],
ctrl: vec![[1.0, 0.0, 0.0, 1.0], [w, w, 0.0, w], [0.0, 1.0, 0.0, 1.0], [1.0, 0.0, 2.0, 1.0], [w, w, 2.0 * w, w], [0.0, 1.0, 2.0, 1.0]],
closed: [false, false],
};
let revolved = EvalSurface::Revolved {
profile: line(Vec3::new(2.0, 0.0, 0.0), Vec3::new(3.0, 0.0, 1.0)),
@@ -531,13 +511,7 @@ mod tests {
fn bilinear_nurbs_patch_and_derivatives() {
// Degree (1, 1) over [0, 2] × [0, 1] with corners lifted: a hyperbolic paraboloid.
let ctrl = vec![[0.0, 0.0, 0.0, 1.0], [2.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0], [2.0, 1.0, 1.0, 1.0]];
let s = EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl,
closed: [false, false],
};
let s = EvalSurface::Nurbs { degree: [1, 1], knots: [vec![0.0, 0.0, 2.0, 2.0], vec![0.0, 0.0, 1.0, 1.0]], count: [2, 2], ctrl };
let Some((p, du, dv)) = s.derivs(1.0, 0.5) else { panic!("evaluates") };
assert!(close(p, Vec3::new(1.0, 0.5, 0.25), 1e-12), "{p:?}");
assert!(close(du, Vec3::new(1.0, 0.0, 0.25), 1e-12), "{du:?}");
@@ -559,7 +533,7 @@ mod tests {
}
let Ok(reference) = crate::bspline::NurbsSurface::new([3, 2], knots.clone(), &pts, &wts) else { panic!("valid surface") };
let ctrl = reference.homogeneous().iter().map(|h| [h.x, h.y, h.z, h.w]).collect();
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl, closed: [false, false] };
let s = EvalSurface::Nurbs { degree: [3, 2], knots, count: [nu, nv], ctrl };
for i in 0..=8 {
for j in 0..=8 {
let (u, v) = (i as f64 / 8.0, j as f64 / 4.0);
@@ -583,13 +557,12 @@ mod tests {
#[test]
fn hostile_surfaces_do_not_panic() {
let bad = [
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![], closed: [false, false] },
EvalSurface::Nurbs { degree: [9, 1], knots: [vec![0.0; 3], vec![0.0; 3]], count: [1, 1], ctrl: vec![] },
EvalSurface::Nurbs {
degree: [1, 1],
knots: [vec![0.0, 0.0, 1.0, 1.0], vec![0.0, 0.0, 1.0, 1.0]],
count: [2, 2],
ctrl: vec![[0.0; 4]; 4],
closed: [false, false],
},
EvalSurface::Revolved {
profile: EvalCurve { degree: 0, knots: vec![], ctrl: vec![] },
+1 -2
View File
@@ -466,9 +466,8 @@ fn pack(plans: &[&TessPlan]) -> Result<Packed> {
push3(&mut data, *du);
push3(&mut data, *dv);
}
EvalSurface::Nurbs { degree, knots, count, ctrl, closed } => {
EvalSurface::Nurbs { degree, knots, count, ctrl } => {
head[0] = 1;
head[11] = u32::from(closed[0]) | (u32::from(closed[1]) << 1);
head[1] = word(degree[0])?;
head[2] = word(degree[1])?;
head[3] = word(count[0])?;
+2 -15
View File
@@ -27,8 +27,7 @@ struct Surface {
// Revolved: parameters exchanged; mirror transform (normal reversed).
swap: u32,
mirror: u32,
// NURBS: bit 0 the surface joins itself along u, bit 1 along v (the direction is periodic).
closed: u32,
_pad: u32,
};
struct Vertex {
@@ -160,20 +159,8 @@ fn rational_der(a: vec4<f32>, da: vec4<f32>) -> vec3<f32> {
return (da.xyz - rational_point(a) * da.w) / a.w;
}
// A parameter outside the knot range of a direction the surface joins itself along, moved by
// whole ranges back into it (the CPU evaluator's `EvalSurface::derivs` does the same).
fn wrap_closed(knots: u32, p: u32, n: u32, t: f32, closed: bool) -> f32 {
if (!closed) { return t; }
let a = data[knots + p];
let b = data[knots + n];
if (!(b > a) || (t >= a && t <= b)) { return t; }
return a + (t - a) - floor((t - a) / (b - a)) * (b - a);
}
fn nurbs(s: Surface, u_in: f32, v_in: f32) -> Derivs {
fn nurbs(s: Surface, u: f32, v: f32) -> Derivs {
var r: Derivs;
let u = wrap_closed(s.knots_u, s.pu, s.nu, u_in, (s.closed & 1u) != 0u);
let v = wrap_closed(s.knots_v, s.pv, s.nv, v_in, (s.closed & 2u) != 0u);
let su = span_of(s.knots_u, s.pu, s.nu, u);
let sv = span_of(s.knots_v, s.pv, s.nv, v);
let bu0 = basis_funs(s.knots_u, su, s.pu, u);
+35 -216
View File
@@ -20,15 +20,7 @@ const VERSION: &str = "solidcraft-container/1";
const LEGACY_VERSION: &str = "solvecraft-container/1";
const PREVIEW_VERSION: &str = "solidcraft-preview/1";
const MAX_ENTRIES: usize = 10_000;
/// An embedded STEP source may be as large as an import accepts. It is always stored
/// Deflate-compressed, so the file itself stays within [`MAX_DESIGN_BYTES`] (#3221).
const MAX_STEP_PART: usize = crate::MAX_STEP_BYTES;
/// Deflate level for STEP sources. Level 1 uses fixed Huffman codes: a 270 MiB STEP export
/// shrank to 70 MB in 1.6 s, at level 2 to 47 MB in 3.1 s; higher levels gain little more.
const STEP_LEVEL: i64 = 2;
/// Every other part is bounded by the file size limit.
const MAX_PART: usize = MAX_DESIGN_BYTES;
const MAX_EXPANDED: usize = MAX_STEP_PART + MAX_DESIGN_BYTES;
const MAX_EXPANDED: usize = 512 << 20;
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
const MAX_PREVIEW_BYTES: usize = 8 << 20;
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
@@ -73,7 +65,7 @@ struct PackageWriter {
impl PackageWriter {
fn part(&mut self, name: &str, bytes: &[u8], compressed: bool) -> Result<()> {
if bytes.len() > if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART } {
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid("part too large"));
}
if !self.names.insert(name.to_owned()) {
@@ -84,21 +76,10 @@ impl PackageWriter {
return Err(invalid("package too large"));
}
let method = if compressed { zip::CompressionMethod::Deflated } else { zip::CompressionMethod::Stored };
let level = if name.ends_with(".step") { STEP_LEVEL } else { 1 };
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(level));
let options = zip::write::SimpleFileOptions::default().compression_method(method).compression_level(compressed.then_some(1));
self.zip.start_file(name, options).map_err(invalid)?;
self.zip.write_all(bytes).map_err(invalid)
}
/// Would `entries` more optional parts of `extra` bytes, stored uncompressed, still keep
/// the file within `budget` and its expanded size within the reader's budget?
fn room(&self, entries: usize, extra: usize, budget: usize) -> bool {
// The bytes written so far, plus what the open compressed part may still hold back.
let written = self.zip.get_ref().map_or(usize::MAX, |c| c.get_ref().len()).saturating_add(1 << 20);
let directory = self.names.len().saturating_add(entries).saturating_mul(256).saturating_add(65_536);
self.names.len().saturating_add(entries) <= MAX_ENTRIES
&& written.saturating_add(extra).saturating_add(directory) <= budget
&& self.expanded.saturating_add(extra) <= MAX_EXPANDED
}
fn blob(&mut self, bytes: &[u8], extension: &str, compressed: bool) -> Result<Value> {
self.resolved =
self.resolved.checked_add(bytes.len()).filter(|&n| n <= MAX_EXPANDED).ok_or_else(|| invalid("resolved payloads too large"))?;
@@ -108,70 +89,9 @@ impl PackageWriter {
}
}
/// Counts what is written without keeping it.
#[derive(Default)]
struct Measure {
position: u64,
length: u64,
}
impl Write for Measure {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.position = self.position.saturating_add(bytes.len() as u64);
self.length = self.length.max(self.position);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Seek for Measure {
fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
let target = match position {
SeekFrom::Start(n) => Some(n),
SeekFrom::End(n) => self.length.checked_add_signed(n),
SeekFrom::Current(n) => self.position.checked_add_signed(n),
};
self.position = target.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "seek out of range"))?;
Ok(self.position)
}
}
/// Room kept in a design file for everything besides one large embedded STEP source.
const STEP_RESERVE: usize = 4 << 20;
/// Can an import's STEP text be saved in a design? It must be no larger than an import
/// accepts, and, compressed as a save stores it, leave room in a design file for the rest of
/// the design. Checked when the file is imported, so it never imports and then fails to save.
pub fn check_step_storable(step: &str) -> Result<()> {
let limit = MAX_DESIGN_BYTES - STEP_RESERVE;
if step.len() > MAX_STEP_PART {
return Err(IoError::Invalid(format!("STEP file too large ({} MB, limit {} MB)", step.len() >> 20, MAX_STEP_PART >> 20)));
}
// Deflate adds at most a few bytes per 16 KiB block: smaller text always fits.
if step.len() <= limit - (limit >> 10) {
return Ok(());
}
let mut zip = zip::ZipWriter::new(Measure::default());
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(STEP_LEVEL));
zip.start_file("blobs/source.step", options).map_err(invalid)?;
zip.write_all(step.as_bytes()).map_err(invalid)?;
let compressed = usize::try_from(zip.finish().map_err(invalid)?.length).unwrap_or(usize::MAX);
if compressed > limit {
return Err(IoError::Invalid(format!(
"STEP file too large to save in a design: {} MB of STEP text compresses to {} MB, over the {} MB a design file may hold",
step.len() >> 20,
compressed >> 20,
limit >> 20
)));
}
Ok(())
}
/// Write a compact container, deduplicating payloads by SHA-256. Mesh arrays are
/// stored directly for low save/open latency; STEP sources and small metadata use
/// fast Deflate. Use the explicit Compact option to compress meshes too.
/// stored directly for low save/open latency; small metadata uses level-one
/// Deflate. Use the explicit Compact option to compress STEP and meshes too.
pub fn write_design_container(doc: &Document) -> Result<Vec<u8>> {
write_design_container_with_compression(doc, DesignCompression::Fast)
}
@@ -225,9 +145,7 @@ fn write_container(
for feature in &doc.features {
let replacements = match &feature.kind {
FeatureKind::Import { step, .. } => {
// STEP is text and shrinks several times: always compressed, so an import as large
// as MAX_STEP_BYTES still fits in a design file.
let blob = writer.blob(step.as_bytes(), "step", true)?;
let blob = writer.blob(step.as_bytes(), "step", compression == DesignCompression::Compact)?;
fields([("step", Value::String(String::new())), ("step_blob", blob)])
}
FeatureKind::MeshImport { meshes, .. } => {
@@ -272,7 +190,14 @@ fn write_container(
// The model cache is optional: one that does not fit the container is left out, never a
// failed save.
let cache = geometry.and_then(|g| native_cache::encode_model(g, &metadata).ok()).filter(|(manifest, data)| {
let fits = writer.room(2, manifest.len().saturating_add(data.len()), budget);
let fits = writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= budget;
if !fits {
log::info!("the model cache ({} MB) does not fit the design file: saved without it", (manifest.len() + data.len()) >> 20);
}
@@ -286,7 +211,14 @@ fn write_container(
writer.part(payload_entry, &data, true)?;
}
if let Some((manifest, data)) = preview.and_then(|p| encode_preview(p, &metadata).ok())
&& writer.room(2, manifest.len().saturating_add(data.len()), budget)
&& writer.names.len().saturating_add(2) <= MAX_ENTRIES
&& writer
.expanded
.saturating_add(manifest.len())
.saturating_add(data.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
<= budget
{
writer.part("cache/preview.json", &manifest, false)?;
writer.part("cache/preview.bin", &data, false)?;
@@ -296,7 +228,7 @@ fn write_container(
}
let bytes = writer.zip.finish().map_err(invalid)?.into_inner();
if bytes.len() > MAX_DESIGN_BYTES {
return Err(invalid(format!("the design would be {} MB, over the {} MB a design file may hold", bytes.len() >> 20, MAX_DESIGN_BYTES >> 20)));
return Err(invalid("compressed file too large"));
}
Ok(bytes)
}
@@ -413,8 +345,7 @@ impl<R: Read + Seek> PackageReader<R> {
return Err(invalid("duplicate or invalid archive entry"));
}
total = total.checked_add(part.size()).ok_or_else(|| invalid("expanded size overflow"))?;
let limit = if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART };
if part.size() > limit as u64 || total > MAX_EXPANDED as u64 {
if part.size() > MAX_DESIGN_BYTES as u64 || total > MAX_EXPANDED as u64 {
return Err(invalid("expanded package too large"));
}
}
@@ -429,7 +360,7 @@ impl<R: Read + Seek> PackageReader<R> {
self.document.as_ref().filter(|d| d.len() <= limit).cloned().ok_or_else(|| invalid("metadata budget"))
}
fn part(&mut self, name: &str) -> Result<Vec<u8>> {
self.limited_part(name, if name.ends_with(".step") { MAX_STEP_PART } else { MAX_PART })
self.limited_part(name, MAX_DESIGN_BYTES)
}
fn limited_part(&mut self, name: &str, limit: usize) -> Result<Vec<u8>> {
let part = self.zip.by_name(name).map_err(|e| invalid(format!("required part `{name}`: {e}")))?;
@@ -438,9 +369,7 @@ impl<R: Read + Seek> PackageReader<R> {
return Err(invalid("part too large"));
}
let mut bytes = Vec::with_capacity(expected as usize);
// A compressed part may expand beyond its declared size (a decompression bomb): stop
// one byte past it instead of at the limit.
part.take(expected + 1).read_to_end(&mut bytes).map_err(invalid)?;
part.take(limit as u64 + 1).read_to_end(&mut bytes).map_err(invalid)?;
if bytes.len() > limit || bytes.len() as u64 != expected {
return Err(invalid("part too large or length mismatch"));
}
@@ -747,7 +676,7 @@ mod tests {
#[test]
fn seekable_preview_defers_large_source_blob_until_document_read() {
let mut doc = Document::new("Deferred source");
let step = noise(2_600_000);
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let preview = DesignPreview { meshes: vec![mesh()] };
@@ -756,12 +685,12 @@ mod tests {
let source = CountingReader { source: Cursor::new(bytes), count: count.clone() };
let mut reader = DesignContainerReader::new(source).unwrap();
assert_eq!(reader.preview().unwrap().meshes[0].positions, preview.meshes[0].positions);
// ZIP directory scans may touch a bounded suffix of a source blob. They
// ZIP directory scans may touch a bounded suffix of a stored blob. They
// must not load its multi-megabyte payload to obtain the saved picture.
assert!(count.get() < 256 * 1024, "read {} bytes before display", count.get());
let restored = reader.read_document().unwrap();
assert_eq!(restored.to_json(), doc.to_json());
assert!(count.get() >= step.len() / 4);
assert!(count.get() >= step.len());
}
#[test]
@@ -769,11 +698,9 @@ mod tests {
let doc = document();
let preview = DesignPreview { meshes: vec![mesh()] };
let mut bytes = write_design_container_with_preview(&doc, DesignCompression::Fast, Some(&preview)).unwrap();
// The first part written is the (compressed) STEP source: damage its data.
let name = u16::from_le_bytes([bytes[26], bytes[27]]) as usize;
let extra = u16::from_le_bytes([bytes[28], bytes[29]]) as usize;
assert!(bytes[30..30 + name].ends_with(b".step"));
bytes[30 + name + extra + 2] ^= 0x55;
let marker = b"ISO-10303-21;";
let offset = bytes.windows(marker.len()).position(|part| part == marker).unwrap();
bytes[offset] ^= 1;
let mut reader = DesignContainerReader::new(Cursor::new(bytes)).unwrap();
assert!(reader.preview().is_some());
assert!(reader.read_document().is_err());
@@ -825,7 +752,7 @@ mod tests {
#[test]
fn native_cache_round_trip_and_preview_defer_original_sources() {
let mut doc = Document::new("Native cache");
let step = noise(2_600_000);
let step = "ISO-10303-21;".repeat(200_000);
doc.add_feature(FeatureKind::Import { file: "large.step".into(), step: step.clone().into(), components: Vec::new(), skip: Vec::new() }, None)
.unwrap();
let bodies = native_geometry();
@@ -840,7 +767,7 @@ mod tests {
assert!(!reader.preview().unwrap().meshes.is_empty());
assert!(count.get() < 256 * 1024, "read {} bytes before native display", count.get());
assert_eq!(reader.read_document().unwrap().to_json(), doc.to_json());
assert!(count.get() >= step.len() / 4);
assert!(count.get() >= step.len());
}
#[test]
@@ -1232,7 +1159,7 @@ mod tests {
assert!(crate::read_design(&duplicate).is_err());
let mut bomb = bytes.clone();
let central = bomb.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
bomb[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
bomb[central + 24..central + 28].copy_from_slice(&((MAX_DESIGN_BYTES + 1) as u32).to_le_bytes());
assert!(crate::read_design(&bomb).unwrap_err().to_string().contains("expanded package too large"));
let mut excessive = bytes;
let end = excessive.windows(4).rposition(|v| v == b"PK\x05\x06").unwrap();
@@ -1240,114 +1167,6 @@ mod tests {
assert!(crate::read_design(&excessive).is_err());
}
/// Printable text that compresses poorly (93 characters: about 6.5 bits each). No `*`, so
/// it never ends a STEP comment.
fn noise(length: usize) -> String {
let mut state = 0x2545_f491_4f6c_dd1du64;
(0..length)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
let c = b'!' + ((state >> 32) % 93) as u8;
char::from(if c >= b'*' { c + 1 } else { c })
})
.collect()
}
/// A valid STEP cube, padded with a comment to `length` bytes. `noisy` padding barely
/// compresses; otherwise it is repetitive like the text of a real large export.
fn padded_step(length: usize, noisy: bool) -> String {
use solidcraft_geom::Vec3;
let cube = solidcraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
let step = solidcraft_kernel::step_export(&[&cube], "padded").unwrap();
let (head, tail) = step.split_at(step.find("DATA;").unwrap() + 5);
let end = length - tail.len() - 2;
let mut out = String::with_capacity(length);
out.push_str(head);
out.push_str("\n/*");
if noisy {
out.push_str(&noise(end - out.len()));
} else {
let mut line = 0u64;
while out.len() + 64 < end {
line += 1;
out.push_str(&format!("#{line}=CARTESIAN_POINT('',({}.,{}.5,-{}.25));\n", line % 997, line % 89, line % 13));
}
out.extend(std::iter::repeat_n(' ', end - out.len()));
}
out.push_str("*/");
out.push_str(tail);
assert_eq!(out.len(), length);
out
}
/// #3221: a STEP file larger than a design part used to import, then fail to save.
#[test]
fn step_over_the_part_limit_imports_saves_compressed_and_reopens() {
let step = padded_step(MAX_DESIGN_BYTES + (1 << 20), false);
let imported = crate::step_import_feature(step.as_bytes(), "large.step").unwrap();
drop(step);
let mut doc = Document::new("Large import");
doc.add_feature(imported.kind, None).unwrap();
let bytes = write_design_container(&doc).unwrap();
assert!(bytes.len() < MAX_DESIGN_BYTES / 2, "saved {} MB", bytes.len() >> 20);
let restored = crate::read_design(&bytes).unwrap();
let (Some(FeatureKind::Import { step: before, .. }), Some(FeatureKind::Import { step: after, .. })) =
(doc.features.first().map(|f| &f.kind), restored.features.first().map(|f| &f.kind))
else {
panic!("the reopened design lost its import");
};
assert!(before.len() > MAX_DESIGN_BYTES && before == after, "the reopened STEP text differs");
let reopened = crate::step_import_feature(after.as_bytes(), "large.step").unwrap();
assert_eq!(reopened.body_names, imported.body_names);
assert_eq!(reopened.import.bodies.len(), 1);
}
#[test]
fn step_that_cannot_be_saved_even_compressed_is_refused_at_import() {
let step = padded_step(MAX_DESIGN_BYTES + (80 << 20), true);
let message = crate::step_import_feature(step.as_bytes(), "noisy.step").err().unwrap().to_string();
assert!(message.contains("too large to save in a design"), "{message}");
}
#[test]
fn designs_with_uncompressed_step_sources_still_open() {
// Saves before #3221 stored the STEP source without compression.
let doc = document();
let entries = parts(&write_design_container(&doc).unwrap());
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
for (name, bytes) in &entries {
let method = if name.ends_with(".step") { zip::CompressionMethod::Stored } else { zip::CompressionMethod::Deflated };
zip.start_file(name, zip::write::SimpleFileOptions::default().compression_method(method)).unwrap();
zip.write_all(bytes).unwrap();
}
let old = zip.finish().unwrap().into_inner();
assert!(old.windows(13).any(|v| v == b"ISO-10303-21;"));
assert_eq!(crate::read_design(&old).unwrap(), doc);
}
#[test]
fn decompression_bomb_is_refused_without_expanding_it() {
// 64 MiB that deflate to a few hundred kilobytes, declared as a 1 KiB STEP source.
let mut doc = Document::new("Bomb");
let step = format!("ISO-10303-21;{}", " ".repeat(64 << 20));
doc.add_feature(FeatureKind::Import { file: "bomb.step".into(), step: step.into(), components: Vec::new(), skip: Vec::new() }, None).unwrap();
let honest = write_design_container(&doc).unwrap();
assert!(honest.len() < 1 << 20);
let central = honest.windows(4).position(|v| v == b"PK\x01\x02").unwrap();
let mut bomb = honest.clone();
// The STEP source is the first part: its local header is at 0.
bomb[22..26].copy_from_slice(&1024u32.to_le_bytes());
bomb[central + 24..central + 28].copy_from_slice(&1024u32.to_le_bytes());
let message = crate::read_design(&bomb).unwrap_err().to_string();
assert!(message.contains("declared uncompressed size") || message.contains("length mismatch"), "{message}");
// An honest declaration over the limit is refused before the part is read at all.
let mut over = honest;
over[central + 24..central + 28].copy_from_slice(&((MAX_STEP_PART + 1) as u32).to_le_bytes());
assert!(crate::read_design(&over).unwrap_err().to_string().contains("expanded package too large"));
}
#[test]
fn rejects_invalid_references_and_repeated_payload_expansion() {
let bytes = write_design_container(&document()).unwrap();
+9 -1
View File
@@ -17,7 +17,15 @@ pub(super) fn write(writer: &mut PackageWriter, png: &[u8], document: &[u8]) ->
}
let key = serde_json::to_vec(&json!({"format": FORMAT, "app": env!("CARGO_PKG_VERSION"), "document": digest(document), "payload": digest(png)}))
.map_err(invalid)?;
if !writer.room(2, key.len().saturating_add(png.len()), MAX_DESIGN_BYTES) {
if writer.names.len().saturating_add(2) > MAX_ENTRIES
|| writer
.expanded
.saturating_add(key.len())
.saturating_add(png.len())
.saturating_add(writer.names.len().saturating_mul(256))
.saturating_add(65_536)
> MAX_DESIGN_BYTES
{
return Ok(());
}
writer.part("cache/thumbnail.json", &key, false)?;
+4
View File
@@ -542,6 +542,8 @@ mod tests {
/// self-inserts fan out to 6^16 visits for no output. Both shapes must be refused quickly.
#[test]
fn empty_blocks_do_not_fan_out() {
let t = std::time::Instant::now();
let fanout = format!(
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nA\n{}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n0\nINSERT\n2\nA\n0\nENDSEC\n0\nEOF\n",
"0\nINSERT\n2\nA\n".repeat(6)
@@ -555,5 +557,7 @@ mod tests {
);
let g = read_dxf(&array).unwrap_or_default();
assert!(g.is_empty(), "an empty block has no geometry to place");
assert!(t.elapsed() < std::time::Duration::from_secs(5), "took {:?}", t.elapsed());
}
}
+12 -33
View File
@@ -101,10 +101,8 @@ pub fn is_iges_path(path: &str) -> bool {
/// nothing), so the caller chooses.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum StepNames {
/// Keep meaningful solid names; replace placeholders with the nearest named product.
#[default]
Auto,
/// The solid's own name in the file, else the name of the product that holds it.
#[default]
Solid,
/// The name of the product (part) that holds the body, else the solid's name.
Product,
@@ -114,10 +112,9 @@ pub enum StepNames {
}
impl StepNames {
/// `auto`, `solid`, `product` or `instance` (any case); `None` for anything else.
/// `solid`, `product` or `instance` (any case); `None` for anything else.
pub fn parse(s: &str) -> Option<StepNames> {
match s.trim().to_ascii_lowercase().as_str() {
"auto" => Some(StepNames::Auto),
"solid" => Some(StepNames::Solid),
"product" => Some(StepNames::Product),
"instance" => Some(StepNames::Instance),
@@ -129,16 +126,7 @@ impl StepNames {
/// A name that says nothing about its body: empty, or the placeholder a file or the importer
/// gives to something it has no name for.
fn generic_name(n: &str) -> bool {
let n = n.trim();
n.is_empty()
|| n.starts_with('(')
|| n.eq_ignore_ascii_case("import")
|| n.eq_ignore_ascii_case("unnamed")
|| ["body", "solid", "shell"].iter().any(|prefix| {
n.get(..prefix.len()).is_some_and(|s| s.eq_ignore_ascii_case(prefix))
&& n.get(prefix.len()..)
.is_some_and(|s| s.chars().all(|c| c.is_ascii_digit() || c.is_ascii_whitespace() || matches!(c, '_' | '-' | '(' | ')')))
})
n.trim().is_empty() || n.starts_with('(') || n.eq_ignore_ascii_case("import") || n.eq_ignore_ascii_case("unnamed")
}
/// The name an imported body gets under the `names` choice; the solid's name when the chosen
@@ -146,8 +134,6 @@ fn generic_name(n: &str) -> bool {
fn body_name(b: &solidcraft_kernel::ImportedBody, names: StepNames) -> String {
let usable = |n: &&String| !generic_name(n);
let chosen = match names {
StepNames::Auto if !generic_name(&b.name) => Some(&b.name),
StepNames::Auto => b.path.iter().rev().find(usable).or_else(|| b.instance.as_ref().filter(usable)),
StepNames::Solid => None,
StepNames::Product => b.path.last().filter(usable),
StepNames::Instance => b.instance.as_ref().filter(usable).or(b.path.last().filter(usable)),
@@ -160,7 +146,8 @@ pub struct StepFeature {
/// Timeline name: the root product's name, else the file name.
pub name: String,
pub kind: solidcraft_doc::FeatureKind,
/// Body names from the file, as [`StepNames`] chose them (automatic by default).
/// Body names from the file, as [`StepNames`] chose them (by default the solid names, else
/// the product names).
pub body_names: Vec<String>,
pub warnings: Vec<String>,
/// The file as read. Hold it until the feature has been evaluated: the timeline then reuses
@@ -216,8 +203,6 @@ pub fn step_import_feature_named(bytes: &[u8], file: &str, names: StepNames) ->
let text = step_text(bytes);
if is_iges_path(file) { solidcraft_kernel::iges_to_step(&text).map_err(IoError::Invalid)? } else { (text, Vec::new()) }
};
// The feature keeps this text and a save stores it: refuse now what could not be saved.
design::check_step_storable(&text)?;
let imp = solidcraft_kernel::step_import_shared(&text)?;
let stem = std::path::Path::new(file)
.file_stem()
@@ -763,7 +748,7 @@ mod tests {
let step = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Step, "t", &meta).unwrap()).unwrap();
assert!(step.contains("FILE_DESCRIPTION(('Vented Geh\\X2\\00E4\\X0\\use for a Pi'),'2;1');"), "{}", &step[..300]);
assert!(step.contains("FILE_NAME('t.step','',('Jane O''Brien'),('ACME'),"), "{}", &step[..300]);
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["t"], "still reads back");
assert_eq!(step_import_feature(step.as_bytes(), "t.step").unwrap().body_names, ["Body1"], "still reads back");
let iges = String::from_utf8(export_with_metadata(&m.state(), &[], Format::Iges, "t", &meta).unwrap()).unwrap();
assert!(iges.contains("12HJane O'Brien") && iges.contains("4HACME"));
let model_xml = |bytes: Vec<u8>| {
@@ -827,7 +812,7 @@ mod tests {
let f = step_import_feature(s.as_bytes(), "/tmp/My Part.step").unwrap();
// Named after the exported product (the export was called `t`).
assert_eq!(f.name, "t");
assert_eq!(f.body_names, ["t"]);
assert_eq!(f.body_names, ["Body1"]);
assert_eq!(f.body_names.len(), 1);
assert!(matches!(&f.kind, FeatureKind::Import { file, .. } if file == "My Part.step"));
let mut imported = Document::new("Imported STEP round trip");
@@ -877,8 +862,9 @@ mod tests {
step_export_products(&products, 0, &StepHeader::default()).unwrap()
}
/// Automatic names keep meaningful solid labels and replace placeholders with part names.
/// Explicit choices still select the solid, product or occurrence.
/// The bodies of a STEP file are named after the solid (the default), the product that holds
/// them, or the assembly occurrence; a name the file leaves out or fills with a placeholder
/// falls back to the next one.
#[test]
fn step_import_names_follow_the_choice() {
let text = frame_step();
@@ -886,15 +872,10 @@ mod tests {
assert_eq!(names(&text, StepNames::Solid), ["Cut-Extrude3", "Boss-Extrude1", "Boss-Extrude1"]);
assert_eq!(names(&text, StepNames::Product), ["Plate", "Post", "Post"]);
assert_eq!(names(&text, StepNames::Instance), ["Plate-1", "Post-1", "Post-2"]);
// Meaningful solid names survive automatic naming, including SolidCraft exports.
// Without a choice it is the solid's name, as before.
assert_eq!(step_import_feature(text.as_bytes(), "frame.step").unwrap().body_names, names(&text, StepNames::Solid));
let generic = text.replace("'Cut-Extrude3'", "'Body1'").replace("'Boss-Extrude1'", "'Body 2 (3)'");
assert_eq!(step_import_feature(generic.as_bytes(), "frame.step").unwrap().body_names, ["Plate", "Post", "Post"]);
assert_eq!(names(&generic, StepNames::Solid), ["Body1", "Body 2 (3)", "Body 2 (3)"]);
let named = generic.replace("'Body1'", "'Body housing'");
assert_eq!(names(&named, StepNames::Auto), ["Body housing", "Post", "Post"]);
// The timeline entry is the root product's name whatever the bodies are called.
for choice in [StepNames::Auto, StepNames::Solid, StepNames::Product, StepNames::Instance] {
for choice in [StepNames::Solid, StepNames::Product, StepNames::Instance] {
assert_eq!(step_import_feature_named(text.as_bytes(), "frame.step", choice).unwrap().name, "Frame");
}
// A product the file leaves unnamed says nothing: its bodies keep the solid's name.
@@ -906,8 +887,6 @@ mod tests {
assert_eq!(names(&part, StepNames::Solid), ["Body1"]);
assert_eq!(names(&part, StepNames::Product), ["Bracket"]);
assert_eq!(names(&part, StepNames::Instance), ["Bracket"]);
assert_eq!(names(&part, StepNames::Auto), ["Bracket"]);
assert_eq!(StepNames::parse(" AuTo "), Some(StepNames::Auto));
assert_eq!(StepNames::parse(" Product "), Some(StepNames::Product));
assert_eq!(StepNames::parse("INSTANCE"), Some(StepNames::Instance));
assert_eq!(StepNames::parse("label"), None);
-15
View File
@@ -467,21 +467,6 @@ outer
survives("3MF (production extension)", &mutations(&production, false, 0x2089), usize::MAX, |b| {
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
});
// Its model parts' XML mutated and zipped again: mutations of the zip mostly stop at its
// checksums, these reach the `p:path` and `objectid` handling.
let parts = crate::threemf::tests::production_parts();
for (k, (name, xml)) in parts.iter().enumerate() {
let rezipped: Vec<Vec<u8>> = mutations(xml.as_bytes(), true, 0x2089 + k as u64)
.iter()
.map(|m| {
let entries: Vec<(&str, &[u8])> = parts.iter().map(|(n, x)| (*n, if n == name { m.as_slice() } else { x.as_bytes() })).collect();
crate::threemf::tests::package_bytes(&entries)
})
.collect();
survives(&format!("3MF (production extension, {name} mutated)"), &rezipped, usize::MAX, |b| {
crate::read_3mf(b).ok().map(|(objects, _)| objects.iter().map(|o| o.triangles.len()).sum())
});
}
}
#[test]
+81 -395
View File
@@ -8,12 +8,10 @@
//! precision so the receiver gets the tessellation exactly. Reading: every build item becomes one mesh
//! (components flattened with their transforms), converted to millimetres. Components and build
//! items may name an object in another model part of the package (`p:path`, the 3MF Production
//! extension, which slicers use for multi-part projects); those parts are read too. Only the root
//! model's build counts (readers must ignore the others), and a part that can't be read fails
//! only the placements that need it. Packages are untrusted: sizes, entry counts, model parts,
//! mesh sizes and component nesting are capped.
//! extension, which slicers use for multi-part projects); those parts are read too. Packages are
//! untrusted: sizes, entry counts, model parts, mesh sizes and component nesting are capped.
use std::collections::{BTreeMap, HashMap};
use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::io::{Cursor, Read, Write};
@@ -354,30 +352,10 @@ fn bad<T>(msg: impl Into<String>) -> Result<T> {
Err(IoError::Invalid(format!("3MF: {}", msg.into())))
}
/// The name a part is looked up by: `%XX` escapes decoded, no leading `/`, in lower case (part
/// names are URIs and compare case-insensitively).
/// The name a part is looked up by: no leading `/`, in lower case (part names compare
/// case-insensitively).
fn part_key(name: &str) -> String {
let b = name.as_bytes();
let hex = |k: usize| b.get(k).and_then(|h| char::from(*h).to_digit(16));
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
match (c, hex(i + 1), hex(i + 2)) {
(b'%', Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
}
(b'\\', ..) => {
out.push(b'/');
i += 1;
}
_ => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).trim_start_matches('/').to_ascii_lowercase()
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
}
fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Option<Vec<u8>>> {
@@ -385,22 +363,20 @@ fn read_part(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Opt
let Some(idx) = (0..zip.len()).find(|i| zip.name_for_index(*i).and_then(|n| n.ok()).is_some_and(|n| part_key(&n) == want)) else {
return Ok(None);
};
match read_entry(zip, idx, name, MAX_PART)? {
Some(data) => Ok(Some(data)),
None => bad(format!("{name} expands to more than 1 GB")),
}
read_entry(zip, idx, name).map(Some)
}
/// Entry `idx` uncompressed, or `None` when it expands to more than `limit` bytes (zip bombs stop
/// there).
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str, limit: u64) -> Result<Option<Vec<u8>>> {
fn read_entry(zip: &mut zip::ZipArchive<Cursor<&[u8]>>, idx: usize, name: &str) -> Result<Vec<u8>> {
let mut f = zip.by_index(idx).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
if f.size() > limit {
return Ok(None);
if f.size() > MAX_PART {
return bad(format!("{name} expands to {} bytes", f.size()));
}
let mut out = Vec::with_capacity(f.size().min(16 << 20) as usize);
(&mut f).take(limit.saturating_add(1)).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
Ok((out.len() as u64 <= limit).then_some(out))
(&mut f).take(MAX_PART + 1).read_to_end(&mut out).map_err(|e| IoError::Invalid(format!("3MF {name}: {e}")))?;
if out.len() as u64 > MAX_PART {
return bad(format!("{name} expands to more than 1 GB"));
}
Ok(out)
}
fn attrs(e: &BytesStart) -> HashMap<String, String> {
@@ -459,16 +435,10 @@ fn det(m: &Xf) -> f64 {
m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6])
}
/// A model part named by `p:path`: its key ([`part_key`]) and the name as written, for messages.
struct PartName {
key: String,
path: String,
}
/// A component or build item: object `id` placed by `xf`, in model part `part` or, without one,
/// in the part that names it.
/// A component or build item: object `id` placed by `xf`, in model part `part` (its key, from
/// `p:path`) or, without one, in the part that names it.
struct Ref {
part: Option<PartName>,
part: Option<String>,
id: u32,
xf: Xf,
}
@@ -483,29 +453,19 @@ struct Object {
}
/// One model part. Positions and translations are in millimetres (converted with the part's
/// unit as it is read), so objects of parts in different units fit together. Objects are kept in
/// id order, so the parts they name are read, and reported, in the same order every run.
/// unit as it is read), so objects of parts in different units fit together.
#[derive(Default)]
struct Model {
objects: BTreeMap<u32, Object>,
unit: f64,
objects: HashMap<u32, Object>,
materials: HashMap<u32, Vec<Option<[f32; 3]>>>,
build: Vec<Ref>,
}
/// The model parts of a package by key: the root model's key, the parts read, the parts that are
/// in the package but could not be read (with the error), and each part's name as first written.
/// The model parts read from a package, by key, and the key of the root model.
struct Package {
root: String,
parts: HashMap<String, Model>,
failed: HashMap<String, String>,
names: HashMap<String, String>,
}
impl Package {
/// Part `key` as the package names it.
fn shown<'a>(&'a self, key: &'a str) -> &'a str {
self.names.get(key).map_or(key, String::as_str)
}
}
fn color(s: &str) -> Option<[f32; 3]> {
@@ -529,19 +489,11 @@ fn unit_mm(u: &str) -> Option<f64> {
})
}
/// The model part's name in `p:path`, if any.
fn part_name(a: &HashMap<String, String>) -> Option<PartName> {
a.get("path").map(|p| PartName { key: part_key(p), path: p.clone() })
}
/// Model part `xml`. Only the `root` model's build items are read: the spec has readers ignore
/// the build sections of the other parts (slicers leave them empty or point them anywhere).
fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
fn parse_model(xml: &[u8]) -> Result<Model> {
let mut rd = Reader::from_reader(xml);
rd.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut m = Model::default();
let mut unit = 1.0;
let mut m = Model { unit: 1.0, ..Default::default() };
let mut depth = 0usize;
let mut cur: Option<(u32, Object)> = None;
let mut cur_mat: Option<u32> = None;
@@ -589,7 +541,7 @@ fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
b"model" => {
seen_model = true;
if let Some(u) = a.get("unit") {
unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
m.unit = unit_mm(u).ok_or_else(|| IoError::Invalid(format!("3MF: unknown unit `{u}`")))?;
}
}
b"basematerials" => {
@@ -621,7 +573,7 @@ fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
return bad("too many vertices");
}
let (Some(x), Some(y), Some(z)) = (num("x"), num("y"), num("z")) else { return bad("vertex without x, y, z") };
o.positions.push(Vec3::new(x, y, z) * unit);
o.positions.push(Vec3::new(x, y, z) * m.unit);
}
b"triangle" => {
let Some((_, o)) = cur.as_mut() else { return bad("triangle outside an object") };
@@ -634,13 +586,11 @@ fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
b"component" => {
let Some((_, o)) = cur.as_mut() else { return bad("component outside an object") };
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: component without objectid".into()))?;
// The spec allows `p:path` only in the root model's components; a reader can still
// follow it from any part (loops and depth are checked when placing).
o.components.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
o.components.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
}
b"item" if root => {
b"item" => {
let target = id("objectid").ok_or_else(|| IoError::Invalid("3MF: build item without objectid".into()))?;
m.build.push(Ref { part: part_name(&a), id: target, xf: in_mm(parse_xf(a.get("transform"))?, unit) });
m.build.push(Ref { part: a.get("path").map(|p| part_key(p)), id: target, xf: in_mm(parse_xf(a.get("transform"))?, m.unit) });
}
_ => {}
}
@@ -652,30 +602,29 @@ fn parse_model(xml: &[u8], root: bool) -> Result<Model> {
Ok(m)
}
/// Queue the model parts `m` names that were not seen yet, noting how each is spelled (`m.build`
/// is empty but for the root).
fn queue_parts(m: &Model, names: &mut HashMap<String, String>, pending: &mut Vec<String>) -> Result<()> {
for p in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
if !names.contains_key(&p.key) {
/// Queue the model parts `m` names that were not seen yet.
fn queue_parts(m: &Model, seen: &mut HashSet<String>, pending: &mut Vec<String>) -> Result<()> {
for key in m.objects.values().flat_map(|o| &o.components).chain(&m.build).filter_map(|r| r.part.as_ref()) {
if !seen.contains(key) {
// A package holds no more parts than it has entries.
if names.len() >= MAX_ENTRIES {
if seen.len() >= MAX_ENTRIES {
return bad(format!("objects name more than {MAX_ENTRIES} model parts"));
}
names.insert(p.key.clone(), p.path.clone());
pending.push(p.key.clone());
seen.insert(key.clone());
pending.push(key.clone());
}
}
Ok(())
}
/// An error reading model part `name`, saying which part it was.
fn in_part(name: &str, e: IoError) -> String {
/// An error reading model part `key`, saying which part it was.
fn in_part(key: &str, e: IoError) -> IoError {
match e {
IoError::Invalid(msg) => {
let msg = msg.strip_prefix("3MF: ").or_else(|| msg.strip_prefix("3MF ")).unwrap_or(&msg);
format!("3MF model part `{name}`: {msg}")
IoError::Invalid(format!("3MF model part `{key}`: {msg}"))
}
e => e.to_string(),
e => e,
}
}
@@ -689,22 +638,15 @@ fn flatten<'a>(
stack: &mut Vec<(&'a str, u32)>,
total: &mut usize,
) -> Result<()> {
// Loops are keyed by (part, object): parts number their objects on their own, so object 1 of
// the root placing object 1 of another part is no loop.
if stack.contains(&(part, id)) {
return bad("components place each other in a loop");
if stack.len() > MAX_COMPONENT_DEPTH || stack.contains(&(part, id)) {
return bad("components nested too deeply or recursive");
}
if stack.len() > MAX_COMPONENT_DEPTH {
return bad(format!("components nested more than {MAX_COMPONENT_DEPTH} deep"));
}
let Some(m) = pkg.parts.get(part) else {
return Err(IoError::Invalid(pkg.failed.get(part).cloned().unwrap_or_else(|| format!("3MF: no model part `{}`", pkg.shown(part)))));
};
let m = pkg.parts.get(part).ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{part}`")))?;
let o = m.objects.get(&id).ok_or_else(|| {
IoError::Invalid(if part == pkg.root {
format!("3MF: missing object {id}")
} else {
format!("3MF: missing object {id} in model part `{}`", pkg.shown(part))
format!("3MF: missing object {id} in model part `{part}`")
})
})?;
// Every placement costs at least one unit of the budget, so objects that only place other
@@ -753,8 +695,7 @@ fn flatten<'a>(
}
stack.push((part, id));
for c in &o.components {
// Without `p:path` a component is in the part that names it, not in the root.
flatten(pkg, c.part.as_ref().map_or(part, |p| p.key.as_str()), c.id, &compose(&c.xf, xf), out, stack, total)?;
flatten(pkg, c.part.as_deref().unwrap_or(part), c.id, &compose(&c.xf, xf), out, stack, total)?;
}
stack.pop();
Ok(())
@@ -792,56 +733,40 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec<MeshObject>, Vec<String>)> {
}
}
let xml = read_part(&mut zip, &root)?.ok_or_else(|| IoError::Invalid(format!("3MF: no model part `{root}`")))?;
let root_name = root;
let root = part_key(&root_name);
let model = parse_model(&xml, true)?;
// All model parts together may expand to 1 GB; only one is held as XML at a time.
let mut model_bytes = xml.len() as u64;
drop(xml);
let root = part_key(&root);
let model = parse_model(&xml)?;
let mut model_bytes = xml.len();
// The model parts objects name (production extension), each read once. The entries are
// indexed first, so a package naming thousands of parts costs no scan per name. A name with
// no entry is left out and a part that can't be read is noted: either way only the
// placements that need it fail, and say why.
// indexed first, so a package naming thousands of parts costs no scan per name; a name
// with no entry is left out, and the placements that need it say so.
let mut entries = HashMap::new();
for i in 0..zip.len() {
if let Some(Ok(n)) = zip.name_for_index(i) {
entries.entry(part_key(&n)).or_insert(i);
}
}
let mut names = HashMap::from([(root.clone(), root_name)]);
let mut seen = HashSet::from([root.clone()]);
let mut pending = Vec::new();
queue_parts(&model, &mut names, &mut pending)?;
queue_parts(&model, &mut seen, &mut pending)?;
let mut parts = HashMap::from([(root.clone(), model)]);
let mut failed = HashMap::new();
while let Some(key) = pending.pop() {
let Some(&idx) = entries.get(&key) else { continue };
let name = names.get(&key).cloned().unwrap_or_else(|| key.clone());
let read = match read_entry(&mut zip, idx, &name, MAX_PART.saturating_sub(model_bytes)) {
Ok(Some(xml)) => {
model_bytes = model_bytes.saturating_add(xml.len() as u64);
parse_model(&xml, false)
}
// The package-wide caps refuse the whole package, not just this part.
Ok(None) => return bad("model parts expand to more than 1 GB"),
Err(e) => Err(e),
};
match read {
Ok(m) => {
queue_parts(&m, &mut names, &mut pending)?;
parts.insert(key, m);
}
Err(e) => {
failed.insert(key, in_part(&name, e));
}
let xml = read_entry(&mut zip, idx, &key)?;
model_bytes = model_bytes.saturating_add(xml.len());
if model_bytes as u64 > MAX_PART {
return bad("model parts expand to more than 1 GB");
}
let m = parse_model(&xml).map_err(|e| in_part(&key, e))?;
queue_parts(&m, &mut seen, &mut pending)?;
parts.insert(key, m);
}
let pkg = Package { root, parts, failed, names };
let pkg = Package { root, parts };
let Some(model) = pkg.parts.get(&pkg.root) else { return bad("no root model") };
let mut out = Vec::new();
let mut warnings = Vec::new();
let mut total = 0usize;
for (k, item) in model.build.iter().enumerate() {
let part = item.part.as_ref().map_or(pkg.root.as_str(), |p| p.key.as_str());
let part = item.part.as_deref().unwrap_or(&pkg.root);
let mut mo = MeshObject::default();
match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) {
Ok(()) if !mo.triangles.is_empty() => {
@@ -889,21 +814,16 @@ pub(crate) mod tests {
}
/// A package of these parts (name, contents).
pub(crate) fn package_bytes(parts: &[(&str, &[u8])]) -> Vec<u8> {
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default();
for (name, data) in parts {
zip.start_file(*name, o).unwrap();
zip.write_all(data).unwrap();
zip.write_all(data.as_bytes()).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// A package of these text parts (name, contents).
fn package_parts(parts: &[(&str, &str)]) -> Vec<u8> {
package_bytes(&parts.iter().map(|(n, d)| (*n, d.as_bytes())).collect::<Vec<_>>())
}
fn package(model: &str, rels: Option<&str>) -> Vec<u8> {
let mut parts: Vec<(&str, &str)> = rels.map(|r| ("_rels/.rels", r)).into_iter().collect();
parts.push(("3D/3dmodel.model", model));
@@ -930,9 +850,9 @@ pub(crate) mod tests {
o.triangles.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2])))).sum::<f64>() / 6.0
}
/// The model parts (name, XML) of a multi-part project as slicers save it: the root model's
/// object is two components in another model part, which holds the meshes.
pub(crate) fn production_parts() -> [(&'static str, String); 2] {
/// A multi-part project as slicers save it: the root model's object is two components in
/// another model part, which holds the meshes.
pub(crate) fn production_package() -> Vec<u8> {
let root = format!(
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources><object id="10" type="model" name="Pair"><components>
<component p:path="/3D/Objects/object_1.model" objectid="1"/>
@@ -940,12 +860,7 @@ pub(crate) mod tests {
</components></object></resources><build><item objectid="10" transform="1 0 0 0 1 0 0 0 1 0 0 2"/></build></model>"#
);
let objects = model_part("millimeter", &format!("{}{}", tet_object(1, "A", 1.0), tet_object(2, "B", 2.0)));
[("3D/3dmodel.model", root), (OBJECTS_PART, objects)]
}
pub(crate) fn production_package() -> Vec<u8> {
let [(r, root), (o, objects)] = production_parts();
package_parts(&[(r, &root), (o, &objects)])
package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])
}
/// Objects kept in a model part of their own (3MF Production extension) come in with their
@@ -1001,40 +916,22 @@ pub(crate) mod tests {
r#"<object id="1" type="model"><components><component p:path="/3D/3dmodel.model" objectid="10"/></components></object>"#,
);
let many: String = (0..=MAX_ENTRIES).map(|i| format!(r#"<component p:path="/p{i}.model" objectid="1"/>"#)).collect();
// Each with the message it must give: before the production extension was read, every
// one of these failed alike, on the first `p:path`.
let cases = [
// A part that isn't in the package (named as written), one outside it and an empty name.
(
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/Missing.model" objectid="1"/>"#))]),
"no model part `/3D/Objects/Missing.model`",
),
(
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
"no model part `/../../etc/passwd`",
),
(package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]), "no model part ``"),
// A part that isn't in the package, one outside it and an empty name.
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/missing.model" objectid="1"/>"#))]),
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/../../etc/passwd" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="" objectid="1"/>"#)), (OBJECTS_PART, &tet)]),
// An object the part doesn't have.
(
package_parts(&[
("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)),
(OBJECTS_PART, &tet),
]),
"missing object 7 in model part `/3D/Objects/object_1.model`",
),
package_parts(&[("3D/3dmodel.model", &root(r#"<component p:path="/3D/Objects/object_1.model" objectid="7"/>"#)), (OBJECTS_PART, &tet)]),
// A part that is no model.
(
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
"model part `/3D/Objects/object_1.model`: no <model> element",
),
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, "not xml")]),
// Parts that place each other in a loop.
(package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]), "components place each other in a loop"),
package_parts(&[("3D/3dmodel.model", &root(to_objects)), (OBJECTS_PART, &looped)]),
// More part names than a package can hold.
(package_parts(&[("3D/3dmodel.model", &root(&many))]), "objects name more than 10000 model parts"),
package_parts(&[("3D/3dmodel.model", &root(&many))]),
];
for (i, (c, msg)) in cases.iter().enumerate() {
let err = read_3mf(c).map(|_| ()).unwrap_err().to_string();
assert!(err.contains(msg), "case {i}: {err}");
for (i, c) in cases.iter().enumerate() {
assert!(read_3mf(c).is_err(), "case {i}");
}
// A placement whose part is missing is reported; the others still come in.
let two = format!(
@@ -1045,221 +942,6 @@ pub(crate) mod tests {
assert!(warnings.iter().any(|w| w.contains("build item 2") && w.contains("gone.model")), "{warnings:?}");
}
/// The root model: `objects`, and a build of `items`.
fn root_model(objects: &str, items: &str) -> String {
format!(r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build>{items}</build></model>"#)
}
/// Object `id` made of components: (`p:path`, or "" for none, and object id).
fn assembly(id: u32, components: &[(&str, u32)]) -> String {
let c: String = components
.iter()
.map(|(path, oid)| match *path {
"" => format!(r#"<component objectid="{oid}"/>"#),
path => format!(r#"<component p:path="{path}" objectid="{oid}"/>"#),
})
.collect();
format!(r#"<object id="{id}" type="model"><components>{c}</components></object>"#)
}
/// A component without `p:path` in another part is in that part, not in the root, which has
/// an object of the same id too (T1 of the review of #2127).
#[test]
fn components_without_a_path_stay_in_their_part() {
let root =
root_model(&format!("{}{}", tet_object(2, "Root", 5.0), assembly(10, &[("/3D/Objects/object_1.model", 1)])), r#"<item objectid="10"/>"#);
let objects = model_part("millimeter", &format!("{}{}", assembly(1, &[("", 2)]), tet_object(2, "Child", 2.0)));
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(objs.len(), 1);
assert!((volume(&objs[0]) - 8.0 / 6.0).abs() < 1e-9, "the child's object 2, not the root's: {}", volume(&objs[0]));
}
/// Parts number their objects on their own, so root object 1 placing object 1 of another part
/// is no loop (T2).
#[test]
fn the_same_id_in_two_parts_is_no_loop() {
let root = root_model(&assembly(1, &[("/3D/Objects/object_1.model", 1)]), r#"<item objectid="1"/>"#);
let objects = model_part("millimeter", &tet_object(1, "A", 1.0));
let (objs, warnings) = read_3mf(&package_parts(&[("3D/3dmodel.model", &root), (OBJECTS_PART, &objects)])).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert!(objs.len() == 1 && (volume(&objs[0]) - 1.0 / 6.0).abs() < 1e-9);
}
/// A package shaped like a slicer project (Bambu Studio, OrcaSlicer): object UUIDs, printable
/// build items, thumbnails, slicer configs, and the meshes in a part of their own with an empty
/// build (T3). Each object on the plate is one body with all its volumes. The slicer's config
/// (names, which volumes are modifiers) isn't read, so the bodies are named `MeshN` and the
/// modifier comes in as solid.
#[test]
fn slicer_project_package() {
let ns = r#"xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p""#;
let uuid = |n: u32| format!("{n:08x}-61cb-4c03-9d28-80fed5dfa1dc");
let component = |oid: u32| {
format!(
r#"<component p:path="/3D/Objects/object_1.model" objectid="{oid}" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>"#,
uuid(100 + oid)
)
};
let root = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US" {ns}>
<metadata name="Application">BambuStudio-02.03.00.70</metadata>
<metadata name="BambuStudio:3mfVersion">1</metadata>
<resources>
<object id="2" p:UUID="{}" type="model"><components>{}</components></object>
<object id="4" p:UUID="{}" type="model"><components>{}{}</components></object>
</resources>
<build p:UUID="{}">
<item objectid="2" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 128 128 0" printable="1"/>
<item objectid="4" p:UUID="{}" transform="1 0 0 0 1 0 0 0 1 90 128 0" printable="1"/>
</build>
</model>"#,
uuid(2),
component(1),
uuid(4),
component(3),
component(5),
uuid(200),
uuid(201),
uuid(202)
);
let mesh = |id: u32, s: f64| tet_object(id, "", s).replace(r#" name="""#, &format!(r#" p:UUID="{}""#, uuid(300 + id)));
let objects = format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<model unit="millimeter" xml:lang="en-US" {ns}>
<metadata name="BambuStudio:3mfVersion">1</metadata>
<resources>{}{}{}</resources>
<build/>
</model>"#,
mesh(1, 10.0),
mesh(3, 20.0),
mesh(5, 4.0)
);
let settings = r#"<?xml version="1.0" encoding="UTF-8"?>
<config>
<object id="2"><metadata key="name" value="Cube"/><part id="1" subtype="normal_part"><metadata key="name" value="Cube"/></part></object>
<object id="4"><metadata key="name" value="Bracket"/><part id="3" subtype="normal_part"/><part id="5" subtype="modifier_part"/></object>
<plate><metadata key="plater_id" value="1"/><metadata key="thumbnail_file" value="Metadata/plate_1.png"/></plate>
</config>"#;
let content_types = r#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
let rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="{MODEL_REL}"/><Relationship Target="/Metadata/plate_1.png" Id="rel-2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail"/></Relationships>"#
);
let model_rels = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Target="/3D/Objects/object_1.model" Id="rel-1" Type="{MODEL_REL}"/></Relationships>"#
);
let png: &[u8] = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR";
let package = package_bytes(&[
("[Content_Types].xml", content_types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("3D/3dmodel.model", root.as_bytes()),
("3D/_rels/3dmodel.model.rels", model_rels.as_bytes()),
(OBJECTS_PART, objects.as_bytes()),
("Metadata/model_settings.config", settings.as_bytes()),
("Metadata/project_settings.config", br#"{"layer_height": "0.2"}"#),
("Metadata/plate_1.png", png),
("Metadata/plate_1_small.png", png),
]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let names: Vec<&str> = objs.iter().map(|o| o.name.as_str()).collect();
assert_eq!(names, ["Mesh1", "Mesh2"]);
assert_eq!((objs[0].triangles.len(), objs[1].triangles.len()), (4, 8));
assert!((volume(&objs[0]) - 1000.0 / 6.0).abs() < 1e-6, "{}", volume(&objs[0]));
assert!((volume(&objs[1]) - (8000.0 + 64.0) / 6.0).abs() < 1e-6, "{}", volume(&objs[1]));
assert!(objs[0].positions.iter().all(|p| p.x >= 128.0 - 1e-9 && p.y >= 128.0 - 1e-9));
}
/// Placements in every part draw on one budget (T4): three parts fanning out 300 ways each
/// (27 million placements of an empty object) stop at it, and only the build item that went
/// over fails.
#[test]
fn parts_share_the_placement_budget() {
let t = std::time::Instant::now();
let fan = |id: u32, path: &str| assembly(id, &vec![(path, 1); 300]);
let root = root_model(&fan(10, "/3D/Objects/a.model"), r#"<item p:path="/3D/Objects/a.model" objectid="9"/><item objectid="10"/>"#);
let a = model_part("millimeter", &format!("{}{}", fan(1, "/3D/Objects/b.model"), tet_object(9, "Tet", 1.0)));
let b = model_part("millimeter", &fan(1, "/3D/Objects/c.model"));
let c = model_part("millimeter", r#"<object id="1" type="model"/>"#);
let package =
package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/a.model", &a), ("3D/Objects/b.model", &b), ("3D/Objects/c.model", &c)]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert_eq!(objs.len(), 1);
assert!(warnings.len() == 1 && warnings[0].starts_with("build item 2") && warnings[0].contains("placements"), "{warnings:?}");
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// A loop through three parts with different object ids is found, and a chain of parts deeper
/// than the cap stops there while one level less still comes in (T5).
#[test]
fn loops_and_deep_chains_across_parts() {
let read = |parts: &[(String, String)]| read_3mf(&package_parts(&parts.iter().map(|(n, x)| (n.as_str(), x.as_str())).collect::<Vec<_>>()));
let part = |name: &str, objects: String| (name.to_string(), model_part("millimeter", &objects));
let looped = [
("3D/3dmodel.model".to_string(), root_model("", r#"<item p:path="/3D/Objects/a.model" objectid="1"/>"#)),
part("3D/Objects/a.model", assembly(1, &[("/3D/Objects/b.model", 2)])),
part("3D/Objects/b.model", assembly(2, &[("/3D/Objects/c.model", 3)])),
part("3D/Objects/c.model", assembly(3, &[("/3D/Objects/a.model", 1)])),
];
let err = read(&looped).map(|_| ()).unwrap_err().to_string();
assert!(err.contains("components place each other in a loop"), "{err}");
// Object k of part k places object k + 1 of part k + 1; the last one is a mesh.
let chain = |n: u32| {
let name = |k: u32| format!("3D/Objects/c{k}.model");
let mut parts = vec![("3D/3dmodel.model".to_string(), root_model("", &format!(r#"<item p:path="/{}" objectid="1"/>"#, name(1))))];
for k in 1..=n {
let object = if k == n { tet_object(k, "End", 1.0) } else { assembly(k, &[(format!("/{}", name(k + 1)).as_str(), k + 1)]) };
parts.push(part(&name(k), object));
}
read(&parts)
};
let deepest = MAX_COMPONENT_DEPTH as u32 + 1;
let (objs, warnings) = chain(deepest).unwrap();
assert!(objs.len() == 1 && warnings.is_empty(), "{warnings:?}");
let err = chain(deepest + 1).map(|_| ()).unwrap_err().to_string();
assert!(err.contains(&format!("components nested more than {MAX_COMPONENT_DEPTH} deep")), "{err}");
}
/// A part that can't be read fails only the placements that need it, and none when only
/// objects nobody places name it. The build sections of other parts are ignored, even ones
/// pointing at a thumbnail or missing their object id (T6).
#[test]
fn broken_parts_and_the_builds_of_other_parts() {
let root = root_model(
&assembly(20, &[("/3D/Objects/unused.model", 1)]),
r#"<item p:path="/3D/Objects/object_1.model" objectid="1"/><item p:path="/3D/Objects/Broken.model" objectid="1"/>"#,
);
let objects = format!(
r#"<?xml version="1.0"?><model unit="millimeter" {PRODUCTION}><resources>{}</resources><build><item p:path="/Metadata/plate_1.png" objectid="1"/><item/></build></model>"#,
tet_object(1, "A", 1.0)
);
let package = package_parts(&[
("3D/3dmodel.model", &root),
(OBJECTS_PART, &objects),
("3D/Objects/broken.model", "not xml"),
("3D/Objects/unused.model", "<model><resources><object id="),
("Metadata/plate_1.png", "\u{89}PNG"),
]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert_eq!(objs.len(), 1);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(
warnings[0].starts_with("build item 2") && warnings[0].contains("model part `/3D/Objects/Broken.model`: no <model> element"),
"{warnings:?}"
);
}
/// Part names are URIs: a `%XX` escape matches the character it stands for, either way round.
#[test]
fn escaped_part_names() {
let root = root_model("", r#"<item p:path="/3D/Objects/my%20part.model" objectid="1"/><item p:path="/3D/Objects/café.model" objectid="1"/>"#);
let tet = model_part("millimeter", &tet_object(1, "A", 1.0));
let package = package_parts(&[("3D/3dmodel.model", &root), ("3D/Objects/my part.model", &tet), ("3D/Objects/caf%C3%A9.model", &tet)]);
let (objs, warnings) = read_3mf(&package).unwrap();
assert!(warnings.is_empty() && objs.len() == 2, "{warnings:?}");
}
#[test]
fn package_is_well_formed() {
let mut t = tetra("A <&> \"b\"");
@@ -1352,6 +1034,7 @@ pub(crate) mod tests {
/// allocation, which no panic guard can intercept.
#[test]
fn many_placements_of_a_big_object_stay_bounded() {
let t = std::time::Instant::now();
let verts: String = (0..100_000).map(|i| format!("<vertex x=\"{}\" y=\"{}\" z=\"0\"/>", i % 100, i / 100)).collect();
let model = format!(
"{}<object id=\"1\" type=\"model\"><mesh><vertices>{verts}</vertices>\
@@ -1363,6 +1046,7 @@ pub(crate) mod tests {
assert_eq!(objs.len(), 10_000);
// Only the three vertices the one triangle reaches are copied per placement.
assert!(objs.iter().all(|o| o.positions.len() == 3 && o.triangles.len() == 1), "{:?}", objs.first().map(|o| o.positions.len()));
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// Objects that only place other objects count toward the budget too: six levels of 300
@@ -1370,6 +1054,7 @@ pub(crate) mod tests {
/// budget instead of running for days.
#[test]
fn nested_fan_out_of_empty_objects_is_bounded() {
let t = std::time::Instant::now();
let components = |child: u32| (0..300).map(|_| format!(r#"<component objectid="{child}"/>"#)).collect::<String>();
let mut objects = r#"<object id="1" type="model"/>"#.to_string();
for id in 2..=7 {
@@ -1378,6 +1063,7 @@ pub(crate) mod tests {
let model = format!(r#"<model unit="millimeter" {PRODUCTION}><resources>{objects}</resources><build><item objectid="7"/></build></model>"#);
let err = read_3mf(&package(&model, None)).unwrap_err().to_string();
assert!(err.contains("placements"), "{err}");
assert!(t.elapsed() < std::time::Duration::from_secs(30), "took {:?}", t.elapsed());
}
/// Triangles are budgeted across every placement, so fan-out cannot outrun the cap.
+17 -81
View File
@@ -23,16 +23,6 @@ pub struct Body {
/// Bounding-box diagonal, measured by [`Body::new`] while it read the vertices anyway; 0
/// means "not measured, walk the geometry". Every kernel tolerance is a fraction of it.
pub(crate) size: f64,
/// How far this body's face boundaries may lie off their surfaces (mm) and still be taken as
/// lying on them. `Some` for a body read from a file that declares its own numeric
/// uncertainty (a STEP file's `GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT`, see
/// `crate::step_in::Units::tol`); `None` for a body this kernel built, which has no such
/// declaration.
///
/// A band face's ring edges are held to it when the importer splits the band (see
/// `crate::step_in::split`). The display planner takes it only where it is more than its own
/// bound, a hundredth of the body (see `accept` in [`crate::tess::plan_face`]).
pub(crate) boundary_tol: Option<f64>,
/// The last volume measured for this solid and the tolerance it was measured at, shared by
/// clones because they share the solid. A boolean measures its operands, and a later boolean
/// takes the result as an operand again, so the same body is measured twice otherwise.
@@ -551,17 +541,6 @@ thread_local! {
pub(crate) static REPHASED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The volume one closed shell encloses, signed by the way its faces face (positive when they
/// face out of it), from a mesh as coarse as the orientation mesh of a solid of its size.
/// `None` when it cannot be meshed.
pub(crate) fn shell_volume(shell: &mt::Shell) -> Option<f64> {
let bounds: Aabb3 = shell.vertex_iter().map(|v| from_p3(v.point())).collect();
let span = if bounds.is_empty() { 1.0 } else { bounds.diagonal() };
let deflection = Deflection { display: true, ..Deflection::chord(orientation_tolerance(span)) };
let v = guard("shell volume", || Ok(shell.robust_triangulation_within(deflection).to_polygon().volume())).ok()?;
v.is_finite().then_some(v)
}
/// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a
/// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the
/// orientation mesh produced is reused instead of meshing the body again.
@@ -646,7 +625,6 @@ impl Body {
Ok(Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
boundary_tol: None,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: Some((tol, missing)),
mesh: None,
@@ -688,7 +666,6 @@ impl Body {
Ok(Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
boundary_tol: None,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: coarse,
mesh: None,
@@ -718,12 +695,7 @@ impl Body {
return Err(KernelError::Invalid("degenerate placement".into()));
}
let rigid = crate::ops::rigid_placement(&a);
let out = Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())?;
// A placement that does not change distances leaves the boundaries exactly as far off
// their surfaces as they were, so the file's own figure still describes them (see
// [`Body::boundary_tol`]); one that scales the part does not, and the copy declares
// nothing. The same rule as the mesh count above.
Ok(if rigid { out.with_boundary_tol(self.boundary_tol) } else { out })
Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
@@ -1009,14 +981,6 @@ impl Body {
self.color
}
/// The same body, with how far its boundaries may lie off their surfaces (mm) as the file it
/// was read from declares (see [`Body::boundary_tol`]). A value that is not finite and above
/// zero clears the declaration, which puts the body back on the kernel's own bound.
pub(crate) fn with_boundary_tol(mut self, tol: Option<f64>) -> Body {
self.boundary_tol = tol.filter(|t| t.is_finite() && *t > 0.0);
self
}
/// The same body with a display colour.
pub fn with_color(mut self, c: Option<[f32; 3]>) -> Body {
self.color = c.filter(|c| c.iter().all(|x| x.is_finite())).map(|c| c.map(|x| x.clamp(0.0, 1.0)));
@@ -1266,19 +1230,15 @@ impl Body {
if let Some(m) = &self.mesh {
return Ok(m.to_mesh());
}
// Two kinds of face truck cannot mesh in its surface's parameters: one trimmed by a
// helix, which turns more than once around its cylinder and comes out with triangles
// across the solid (#1289), and one that runs across the join of a B-spline surface that
// closes on itself, which comes out folded over itself or with a hole in it.
// Such a body is meshed from its display plan, which cuts and unwraps those faces.
let planned =
guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid) || crate::tess::seam::closes_on_itself(&self.solid)));
if planned.unwrap_or(false) {
// A face trimmed by a helix (a modelled thread from another CAD system) turns more than
// once around its cylinder; truck meshes it with triangles across the solid (#1289).
// Such a body is meshed from its display plan, which cuts the face along a seam.
if guard("tessellate", || Ok(crate::tess::seam::turns_more_than_once(&self.solid))).unwrap_or(false) {
let turn = (angle.is_finite() && angle > 0.0)
.then(|| crate::tess::size::Turn { angle: angle.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol)) {
match guard("tessellate", || crate::tess::plan(&self.solid, tol, turn)) {
Ok((plan, _)) => return Ok(plan.evaluate()),
Err(e) => log::debug!("no display plan ({e}); meshing the reference way"),
Err(e) => log::debug!("helical faces: no display plan ({e}); meshing the reference way"),
}
}
let deflection = if angle.is_finite() && angle > 0.0 {
@@ -1387,7 +1347,7 @@ impl Body {
// The same angle and floor as the reference mesher's angular display mesh.
let turn =
angle.filter(|a| a.is_finite()).map(|a| crate::tess::size::Turn { angle: a.max(MIN_DISPLAY_ANGLE), floor: tol * DISPLAY_ANGLE_FLOOR });
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn, self.boundary_tol).map(|(plan, _)| plan))
guard("tessellate", || crate::tess::plan(&self.solid, tol, turn).map(|(plan, _)| plan))
}
/// Planar faces whose boundary crosses itself: two of its loops crossing each other, or a
@@ -1472,49 +1432,25 @@ impl Body {
Ok(crossed)
}
/// Faces with no triangles of their own, and so not shown: none in a coarse tessellation,
/// and none in the display plan either (the plan is what the viewport draws, and it meshes
/// faces the reference mesher gives up on). Only faces the coarse tessellation misses are
/// looked for in the plan, so a sound body costs no planning.
/// Faces with no triangles in a coarse tessellation (the mesher failed or gave up).
pub fn unmeshed_faces(&self) -> usize {
if self.is_mesh() {
return 0;
}
let tol = orientation_tolerance(self.size());
// The count [`Body::new`] already took at this tolerance, else the same count made now.
let missing = match self.coarse_missing {
Some((cached_tol, missing)) if cached_tol == tol => missing,
_ => {
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
let mut has = vec![false; self.face_count()];
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
*h = true;
}
}
has.iter().filter(|h| !**h).count()
}
};
// A body the coarse mesh covers whole is shown whole: no plan is made for it, whether the
// count came from the cache or from the mesh above.
if missing == 0 {
return 0;
if let Some((cached_tol, missing)) = self.coarse_missing
&& cached_tol == tol
{
return missing;
}
self.unmeshed_in_plan(tol).unwrap_or(missing)
}
/// Faces the display plan leaves without triangles; `None` when the body has no plan.
fn unmeshed_in_plan(&self, tol: f64) -> Option<usize> {
let plan = self.display_plan(tol).ok()?;
let Ok(m) = self.tessellate(tol) else { return self.face_count() };
let mut has = vec![false; self.face_count()];
for f in &plan.faces {
if !f.triangles.is_empty()
&& let Some(h) = has.get_mut(f.face as usize)
{
for f in &m.tri_face {
if let Some(h) = has.get_mut(*f as usize) {
*h = true;
}
}
Some(has.iter().filter(|h| !**h).count())
has.iter().filter(|h| !**h).count()
}
/// Display mesh: like [`Body::tessellate`], with seam edges flagged. Meshed from the
+12 -47
View File
@@ -1986,19 +1986,6 @@ fn loft_reversed(sections: &[(Plane, Loop2)]) -> Result<bool> {
}
fn loft_numbered_wires(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<(Vec<mt::Wire>, Vec<Vec<Vec<usize>>>)> {
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, r) in loft_section_loops(sections, reversed)? {
wires.push(uniform_wire(&p, &r.lp)?);
edge_sources.push(r.from);
}
Ok((wires, edge_sources))
}
/// The sections of a loft as the wires are built from them: in loft order, oriented alike,
/// re-split to the same number of segments and started where they line up best.
pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: bool) -> Result<Vec<(Plane, NumberedLoop)>> {
let n = sections.iter().map(|(_, l)| l.lp.segs.len()).max().unwrap_or(0);
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid("empty section".into()));
@@ -2013,51 +2000,28 @@ pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: b
// with the most segments.
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
let parallel = ordered.iter().all(|(p, _)| p.normal().dot(n0).abs() > 1.0 - 1e-9);
// Whether a section faces the first one's way is followed neighbour to neighbour, not by its
// normal against the first's: planes turning about an axis pass 90 degrees and would be
// flipped, folding the loft (#2847).
let mut facing = true;
let mut before = n0;
let oriented: Vec<(Plane, NumberedLoop)> = ordered
.iter()
.map(|(p, l)| {
let n = p.normal();
if n.dot(before) < 0.0 {
facing = !facing;
}
before = n;
(*p, l.oriented(!facing))
let ccw = p.normal().dot(n0) >= 0.0;
(*p, l.oriented(!ccw))
})
.collect();
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
let lead_c = lead.0.to_world(lead.1.lp.centroid());
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
// The share of the lead's perimeter before each of its vertices: where a circle section
// breaks when the lead's vertex directions do not go round in order.
let lead_len: f64 = lead.1.lp.segs.iter().map(Seg2::length).sum();
let fractions: Vec<f64> = lead
.1
.lp
.segs
.iter()
.scan(0.0, |before, s| {
let f = *before / lead_len;
*before += s.length();
Some(f)
})
.collect();
let mut out: Vec<(Plane, NumberedLoop)> = Vec::new();
let mut wires: Vec<mt::Wire> = Vec::new();
// Per section, the profile segments under each of its wire's edges.
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
for (p, l) in &oriented {
let c = l.lp.centroid();
// Break angles: the lead section's vertex directions seen in this plane.
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
let mut r = l.resample(n, &angles);
// Circle resampling creates increasing local angles. An antiparallel sketch
// plane must run clockwise locally to keep the same winding in world space (the lead's
// directions go round clockwise there). Curving a run through a right angle does not
// reverse a section's winding.
let flip = parallel && p.normal().dot(n0) < 0.0;
let mut r = l.resample(n, &angles, &fractions, flip);
if flip {
// plane must run clockwise locally to keep the same winding in world space.
// Curving a run through a right angle does not reverse a section's winding.
if parallel && p.normal().dot(n0) < 0.0 {
r = r.oriented(true);
}
// Start where every vertex lines up best with the lead's vertex of the same number (the
@@ -2072,9 +2036,10 @@ pub(crate) fn loft_section_loops(sections: &[(Plane, NumberedLoop)], reversed: b
if let Some(k) = (0..m).min_by(|a, b| fit(*b).total_cmp(&fit(*a))) {
r.rotate_left(k);
}
out.push((*p, r));
wires.push(uniform_wire(p, &r.lp)?);
edge_sources.push(r.from);
}
Ok(out)
Ok((wires, edge_sources))
}
/// Ruled faces between each two neighbouring section wires; each comes from the section edges
+4 -9
View File
@@ -228,10 +228,8 @@ fn a_small_hole_on_a_large_plate_has_a_round_rim() {
let segments = (TAU / DISPLAY_ANGLE).ceil() as usize;
assert_eq!(segments, 18);
for z in [0.0, 2.0] {
// The chord tolerance alone leaves each half circle one segment, the rim a line no face
// can be bounded by: its edges are divided again, and the rim stays a coarse polygon.
let (n, _) = rim(&coarse, centre, 2.0, z);
assert!((3..segments).contains(&n), "{n} segments around the rim at the chord tolerance");
// The chord tolerance alone: each half circle is one segment, the rim a line.
assert_eq!(rim(&coarse, centre, 2.0, z).0, 2);
let (n, widest) = rim(&round, centre, 2.0, z);
assert!(n >= segments, "{n} segments around the rim at z = {z}");
assert!(widest <= DISPLAY_ANGLE * 1.001, "a segment spans {} degrees", widest.to_degrees());
@@ -293,12 +291,9 @@ fn a_face_crowded_with_holes_gets_a_coarser_angle_and_bounded_work() {
let boundary = |m: &Mesh, z: f64| m.edges.iter().filter(|e| e.iter().all(|p| (p.z - z).abs() < 1e-6)).map(|e| e.len() - 1).sum::<usize>();
let budget = truck_meshalgo::tessellation::Deflection::MAX_ANGLE_BOUNDARY;
for z in [0.0, 2.0] {
// At the chord tolerance alone each rim is divided only as far as a face can be bounded
// by it (two segments, a line, are divided again).
assert!(boundary(&coarse, z) < 6 * centres.len(), "{}", boundary(&coarse, z));
assert_eq!(boundary(&coarse, z), 4 + 2 * centres.len());
let n = boundary(&round, z);
let (rounder, chord) = (rim(&round, centres[0], 2.0, z).0, rim(&coarse, centres[0], 2.0, z).0);
assert!(rounder >= chord, "the rims are at least as round as the chord tolerance leaves them: {rounder} < {chord}");
assert!(n > boundary(&coarse, z), "the rims are still rounder than the chord tolerance leaves them: {n}");
assert!(n <= budget, "{n} segments bound the face at z = {z}");
}
// Every rim got the same share, and the walls follow their rims.
+73 -198
View File
@@ -9,8 +9,6 @@
use std::collections::HashMap;
use std::fmt::Write as _;
mod assembly;
/// Largest IGES file we read.
pub const MAX_IGES_BYTES: usize = 512 << 20;
const MAX_ENTITIES: usize = 2_000_000;
@@ -429,8 +427,6 @@ struct Tr<'a> {
curve_work: usize,
/// Composite curves (102) on the path being expanded.
composites: Vec<i64>,
/// Farthest a trim loop's end was moved to close a gap (mm; see `close_gaps`).
snapped: f64,
}
fn xf_key(x: &Xf) -> [u64; 12] {
@@ -664,8 +660,6 @@ impl<'a> Tr<'a> {
self.nurbs_curve_in(seq, x, self.f.unit)
}
fn nurbs_curve_in(&self, seq: i64, x: &Xf, unit: f64) -> R<Nurbs> {
/// Knot spans shorter than this are moved onto (0, 1).
const MIN_KNOT_SPAN: f64 = 1e-4;
let k = self.i(seq, 1)?;
let m = self.i(seq, 2)?;
if !(0..=MAX_LIST as i64).contains(&k) || !(1..=32).contains(&m) || m > k.max(1) + 1 {
@@ -691,21 +685,11 @@ impl<'a> Tr<'a> {
if knots.windows(2).any(|w| w[1] < w[0]) {
return Err(format!("entity {seq}: knots out of order"));
}
let mut nb = Nurbs { deg: m, knots, pts };
let nb = Nurbs { deg: m, knots, pts };
let (a, b) = nb.range();
if !(b > a) {
return Err(format!("entity {seq}: empty parameter range"));
}
// Some exporters write a curve over a knot span of 1e-7 or less. The STEP side reads a
// span that small as a single value and refused the curve, losing the faces it bounds.
// Moving and scaling the knots onto (0, 1) leaves the curve's shape as it is; nothing
// else refers to its parameters.
if b - a < MIN_KNOT_SPAN {
let scale = 1.0 / (b - a);
for k in &mut nb.knots {
*k = (*k - a) * scale;
}
}
Ok(nb)
}
@@ -1036,35 +1020,24 @@ struct Sewer {
}
impl Sewer {
fn key(&self, p: V3) -> [i64; 3] {
fn vertex(&mut self, p: V3) -> usize {
// Neighbouring buckets are this key plus or minus one, so leave room for that: a
// coordinate near f64's range saturates the cast and the addition would overflow.
let c = |x: f64| (x / self.tol).floor().clamp(-9e15, 9e15) as i64;
[c(p[0]), c(p[1]), c(p[2])]
}
/// The vertex within the tolerance of `p`, if there is one.
fn find(&self, p: V3) -> Option<usize> {
let key = self.key(p);
let key = [c(p[0]), c(p[1]), c(p[2])];
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
if let Some(list) = self.grid.get(&[key[0] + dx, key[1] + dy, key[2] + dz]) {
for &i in list {
if self.verts.get(i).is_some_and(|q| norm(sub(*q, p)) <= self.tol) {
return Some(i);
return i;
}
}
}
}
}
}
None
}
fn vertex(&mut self, p: V3) -> usize {
if let Some(i) = self.find(p) {
return i;
}
let key = self.key(p);
self.verts.push(p);
self.grid.entry(key).or_default().push(self.verts.len() - 1);
self.verts.len() - 1
@@ -1240,21 +1213,12 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
}
let f = parse(text)?;
let tol = (f.resolution * f.unit).clamp(1e-6, 0.05).max(1e-5);
let mut t = Tr {
f: &f,
o: Out { lines: Vec::new() },
warnings: Vec::new(),
tol,
surf_cache: HashMap::new(),
curve_work: 0,
composites: Vec::new(),
snapped: 0.0,
};
let mut t =
Tr { f: &f, o: Out { lines: Vec::new() }, warnings: Vec::new(), tol, surf_cache: HashMap::new(), curve_work: 0, composites: Vec::new() };
// Header: units and context.
let mm = t.o.add("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
let rad = t.o.add("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))");
let sr = t.o.add("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())");
let unc_line = t.o.lines.len();
let unc = t.o.add(format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol)));
let ctx = t.o.add(format!(
"(GEOMETRIC_REPRESENTATION_CONTEXT(3)GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))GLOBAL_UNIT_ASSIGNED_CONTEXT((#{mm},#{rad},#{sr}))REPRESENTATION_CONTEXT('',''))"
@@ -1264,49 +1228,77 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
// Entities used by others (a B-rep's faces, a trimmed surface's curves) are not roots;
// physically dependent entities have status digits 01 in columns 3–4, but files are lax:
// collect references instead.
let assembly = assembly::Assembly::read(&t, &seqs)?;
let mut solids = HashMap::new();
let mut solids: Vec<(u64, String)> = Vec::new();
let mut styles: Vec<(u64, [f32; 3])> = Vec::new();
let mut n = 0usize;
let mut fallback = String::new();
let watch = crate::watch::current();
for &s in &seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let Some(de) = f.des.get(&s) else { continue };
if de.typ != 186 {
continue;
}
n += 1;
let name = if let Some(name) = assembly::entity_name(&f, s) {
name
} else {
fallback.clear();
let _ = write!(fallback, "Body{n}");
fallback.as_str()
// A name property (406 form 15) among the solid's trailing pointers, else the label.
let named = f.params.get(&s).and_then(|p| {
p.iter().skip(4).filter_map(|x| int(x)).filter(|x| *x > 0).find_map(|ptr| {
let d = f.des.get(&usize::try_from(ptr).ok()?)?;
(d.typ == 406 && d.form == 15).then(|| f.params.get(&usize::try_from(ptr).ok()?)?.get(2).cloned()).flatten()
})
});
let name = match named.filter(|x| !x.trim().is_empty()) {
Some(x) => x.trim().to_string(),
None if de.label.is_empty() => format!("Body{n}"),
None => de.label.clone(),
};
match brep(&mut t, s as i64, &mut styles) {
Ok((id, color)) => {
let named = {
// Re-label the solid with its name.
let line = t.o.lines.get(id as usize - 1).cloned().unwrap_or_default();
line.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1)
};
if let Some(l) = t.o.lines.get_mut(id as usize - 1) {
*l = l.replacen("('',", &format!("('{}',", name.replace('\'', "''")), 1);
*l = named;
}
if let Some(c) = color {
styles.push((id, c));
}
solids.insert(s, id);
solids.push((id, name));
}
Err(e) => t.warn(format!("solid {name}: not read: {e}")),
}
}
assembly.write(&mut t, &seqs, &solids, &mut styles, ctx)?;
if t.snapped > 0.0 {
// The STEP reader takes vertices up to ten times the stated accuracy off their curves:
// state enough to cover the ends `close_gaps` moved, and no more.
if let Some(l) = t.o.lines.get_mut(unc_line) {
*l = format!("UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE({}),#{mm},'distance_accuracy_value','')", r(tol.max(t.snapped * 0.2)));
// Trimmed and bounded surfaces not used by anything else: sewn into shells.
let mut faces = Vec::new();
let mut sw = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
if solids.is_empty() {
for &s in &seqs {
let Some(de) = f.des.get(&s) else { continue };
if de.typ != 144 && de.typ != 143 && !(de.typ == 128 && is_root(&f, s)) {
continue;
}
match trimmed(&mut t, &mut sw, s as i64) {
Ok(fb) => faces.push(fb),
Err(e) => t.warn(format!("surface {s}: not read: {e}")),
}
}
if !faces.is_empty() {
orient(&mut faces);
let (shell, closed) = write_shell(&mut t.o, &sw, &faces, &mut styles);
let item = if closed {
t.o.add(format!("MANIFOLD_SOLID_BREP('Body1',#{shell})"))
} else {
t.warn("the surfaces do not close into a solid: imported as an open body");
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('Body1',(#{shell}))"))
};
solids.push((item, "Body1".into()));
}
}
if solids.is_empty() {
let why = t.warnings.first().cloned().unwrap_or_else(|| "no solids or trimmed surfaces".into());
return Err(format!("IGES import: nothing to read ({why})"));
}
let items = solids.iter().map(|(i, _)| format!("#{i}")).collect::<Vec<_>>().join(",");
t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({items}),#{ctx})"));
for (item, c) in styles {
let rgb = t.o.add(format!("COLOUR_RGB('',{},{},{})", r(c[0] as f64), r(c[1] as f64), r(c[2] as f64)));
let fasc = t.o.add(format!("FILL_AREA_STYLE_COLOUR('',#{rgb})"));
@@ -1327,140 +1319,23 @@ fn translate(text: &str) -> std::result::Result<(String, Vec<String>), String> {
Ok((s, t.warnings))
}
/// Sew surfaces within their owning group, keeping different parts separate.
fn sew_surfaces(
t: &mut Tr,
seqs: impl Iterator<Item = usize>,
name: &str,
styles: &mut Vec<(u64, [f32; 3])>,
faces: &mut Vec<FaceB>,
sw: &mut Sewer,
) -> R<Option<u64>> {
faces.clear();
sw.verts.clear();
sw.grid.clear();
sw.edges.clear();
sw.by_ends.clear();
let watch = crate::watch::current();
let mut drafts = Vec::new();
for seq in seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
match trimmed(t, sw.tol, seq as i64) {
Ok(face) => drafts.push(face),
Err(e) => t.warn(format!("surface {seq}: not read: {e}")),
}
}
close_gaps(t, &mut drafts, sw.tol);
for d in drafts {
let loops = d.loops.iter().map(|l| l.iter().map(|s| sw.edge(s)).collect()).collect();
faces.push(FaceB { surface: d.surface, sense: true, loops, color: d.color });
}
if faces.is_empty() {
return Ok(None);
}
orient(faces);
let (shell, closed) = write_shell(&mut t.o, sw, faces, styles);
let name = name.replace('\'', "''");
Ok(Some(if closed {
t.o.add(format!("MANIFOLD_SOLID_BREP('{name}',#{shell})"))
} else {
t.warn("the surfaces do not close into a solid: imported as an open body");
t.o.add(format!("SHELL_BASED_SURFACE_MODEL('{name}',(#{shell}))"))
}))
}
/// A face read from a trimmed or bounded surface, its loops not yet sewn.
struct Draft {
surface: u64,
loops: Vec<Vec<Seg>>,
color: Option<[f32; 3]>,
}
/// Gaps between the pieces of a trim loop wider than this (mm) are left open: past it, the ends
/// may be different corners of the model rather than one corner written loosely.
const MAX_TRIM_GAP: f64 = 0.1;
/// ...and a gap must also be at most this share of the shorter piece on either side.
const MAX_TRIM_GAP_SHARE: f64 = 0.05;
/// Close the small gaps between consecutive pieces of the faces' trim loops. Some exporters
/// write each piece's model-space curve separately, and consecutive pieces can then miss by a
/// few micrometres to a tenth of a millimetre, well past the file's stated resolution: the loop
/// does not close, its face is lost and the shell stays open. The two ends of each such gap, and
/// every other piece end at either of them (the neighbouring faces at that corner), move to
/// their mean, so all the faces there sew to one vertex. A gap wider than `MAX_TRIM_GAP`, or not
/// small next to its pieces, is left open; the sewing tolerance stays the file's.
fn close_gaps(t: &mut Tr, faces: &mut [Draft], tol: f64) {
let length = |s: &Seg| norm(sub(s.q, s.a)) + norm(sub(s.mid, s.q)) + norm(sub(s.q3, s.mid)) + norm(sub(s.b, s.q3));
// The gaps' ends as vertices, joined into groups (union-find).
let mut ends = Sewer { tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
let mut parent: Vec<usize> = Vec::new();
let root = |parent: &[usize], mut i: usize| {
while let Some(&p) = parent.get(i).filter(|p| **p != i) {
i = p;
}
i
};
for l in faces.iter().flat_map(|f| f.loops.iter()) {
for (i, prev) in l.iter().enumerate() {
let Some(next) = l.get((i + 1) % l.len()) else { continue };
let gap = norm(sub(next.a, prev.b));
if gap <= tol || gap > MAX_TRIM_GAP || gap > MAX_TRIM_GAP_SHARE * length(prev).min(length(next)) {
continue;
}
let (a, b) = (ends.vertex(prev.b), ends.vertex(next.a));
while parent.len() < ends.verts.len() {
parent.push(parent.len());
}
let (ra, rb) = (root(&parent, a), root(&parent, b));
if let Some(x) = parent.get_mut(ra) {
*x = rb;
}
}
}
if ends.verts.is_empty() {
return;
}
// Each group's mean; a group that spreads wider than a gap may be is left alone.
let mut sums: HashMap<usize, (V3, usize)> = HashMap::new();
for (i, p) in ends.verts.iter().enumerate() {
let e = sums.entry(root(&parent, i)).or_insert(([0.0; 3], 0));
e.0 = add(e.0, *p);
e.1 += 1;
}
let mean: HashMap<usize, V3> = sums.into_iter().map(|(r, (s, n))| (r, mul(s, 1.0 / n as f64))).collect();
let mut to: Vec<Option<V3>> = ends.verts.iter().enumerate().map(|(i, _)| mean.get(&root(&parent, i)).copied()).collect();
for (i, p) in ends.verts.iter().enumerate() {
if to.get(i).copied().flatten().is_some_and(|m| norm(sub(m, *p)) > MAX_TRIM_GAP / 2.0) {
let r = root(&parent, i);
for (k, x) in to.iter_mut().enumerate() {
if root(&parent, k) == r {
*x = None;
}
}
}
}
let mut moved = 0.0f64;
let mut snap = |p: &mut V3| {
if let Some(m) = ends.find(*p).and_then(|i| to.get(i).copied().flatten()) {
moved = moved.max(norm(sub(m, *p)));
*p = m;
}
};
for s in faces.iter_mut().flat_map(|f| f.loops.iter_mut()).flat_map(|l| l.iter_mut()) {
snap(&mut s.a);
snap(&mut s.b);
}
t.snapped = t.snapped.max(moved);
/// A surface entity nobody refers to (a bare B-spline patch is a face of its own).
fn is_root(f: &Iges, seq: usize) -> bool {
let target = seq.to_string();
!f.params
.iter()
.any(|(s, p)| *s != seq && f.des.get(s).is_some_and(|d| d.typ != 406 && d.typ != 402) && p.iter().skip(1).any(|x| x.trim() == target))
}
/// A trimmed (144) or bounded (143) surface, or a bare B-spline surface, as a face.
fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
fn trimmed(t: &mut Tr, sw: &mut Sewer, seq: i64) -> R<FaceB> {
let de = t.de(seq)?;
let xf = t.own_xf(seq, 0)?;
let color = color_of(t, seq);
let loop_of = |segs: Vec<Seg>| -> Vec<Seg> { segs.into_iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).collect() };
let loop_of = |_t: &mut Tr, sw: &mut Sewer, segs: Vec<Seg>| -> Vec<(usize, bool)> {
let tol = sw.tol;
segs.iter().filter(|s| norm(sub(s.a, s.b)) > tol || norm(sub(s.a, s.mid)) > tol).map(|s| sw.edge(s)).collect()
};
match de.typ {
144 => {
let surf_seq = t.i(seq, 1)?;
@@ -1469,16 +1344,16 @@ fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
let n2 = t.i(seq, 3)?.clamp(0, MAX_LIST as i64) as usize;
let mut loops = Vec::new();
let outer = if n1 == 0 { t.natural_boundary(surf_seq, &xf)? } else { cos_curve(t, t.i(seq, 4)?, &xf, surf_seq)? };
loops.push(loop_of(outer));
loops.push(loop_of(t, sw, outer));
for k in 0..n2 {
let segs = cos_curve(t, t.i(seq, 5 + k)?, &xf, surf_seq)?;
loops.push(loop_of(segs));
loops.push(loop_of(t, sw, segs));
}
loops.retain(|l| !l.is_empty());
if loops.is_empty() {
return Err("no boundary".into());
}
Ok(Draft { surface, loops, color })
Ok(FaceB { surface, sense: true, loops, color })
}
143 => {
let surf_seq = t.i(seq, 2)?;
@@ -1488,19 +1363,19 @@ fn trimmed(t: &mut Tr, tol: f64, seq: i64) -> R<Draft> {
for k in 0..n {
let b = t.i(seq, 4 + k)?;
let segs = boundary_141(t, b, &xf)?;
loops.push(loop_of(segs));
loops.push(loop_of(t, sw, segs));
}
loops.retain(|l| !l.is_empty());
if loops.is_empty() {
return Err("no boundary".into());
}
Ok(Draft { surface, loops, color })
Ok(FaceB { surface, sense: true, loops, color })
}
_ => {
let surface = t.surface(seq, &Xf::ID)?;
let outer = t.natural_boundary(seq, &Xf::ID)?;
let l = loop_of(outer);
Ok(Draft { surface, loops: vec![l], color })
let l = loop_of(t, sw, outer);
Ok(FaceB { surface, sense: true, loops: vec![l], color })
}
}
}
-280
View File
@@ -1,280 +0,0 @@
//! IGES groups (402) and subfigures (308/408), from the published IGES specification.
//! Definitions become STEP products; instances refer to them without copying geometry.
//! Reference: https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nbsir88-3813.pdf
use super::*;
use std::collections::HashSet;
const MAX_GROUPS: usize = 10_000;
const MAX_INSTANCES: usize = 100_000;
const MAX_REFERENCES: usize = 4 * MAX_ENTITIES;
struct Group {
seq: usize,
members: Vec<usize>,
}
struct Instance {
definition: usize,
transform: Xf,
}
pub(super) struct Assembly {
groups: Vec<Group>,
indices: HashMap<usize, usize>,
instances: HashMap<usize, Instance>,
referenced: HashSet<usize>,
surface_references: HashSet<usize>,
}
impl Assembly {
pub(super) fn read(t: &Tr, seqs: &[usize]) -> R<Self> {
let mut out = Self {
groups: Vec::new(),
indices: HashMap::new(),
instances: HashMap::new(),
referenced: HashSet::new(),
surface_references: HashSet::new(),
};
let mut references = 0usize;
let watch = crate::watch::current();
for &seq in seqs {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let de = t.de(seq as i64)?;
if de.typ == 308 || (de.typ == 402 && matches!(de.form, 1 | 7 | 14 | 15)) {
if out.groups.len() >= MAX_GROUPS {
return Err("too many IGES assembly definitions".into());
}
let field = if de.typ == 308 { 3 } else { 1 };
let count = usize::try_from(t.i(seq as i64, field)?).map_err(|_| "negative IGES group size")?;
references = references.saturating_add(count);
if count > MAX_LIST || references > MAX_REFERENCES {
return Err("IGES assembly references exceed the work limit".into());
}
let p = t.p(seq as i64)?;
let entries = p.get(field + 1..field + 1 + count).ok_or("truncated IGES group")?;
let mut members = Vec::with_capacity(count);
for entry in entries {
let child = int(entry).and_then(|x| usize::try_from(x).ok()).filter(|x| *x > 0).ok_or("invalid IGES group reference")?;
t.de(child as i64)?;
members.push(child);
out.referenced.insert(child);
}
out.indices.insert(seq, out.groups.len());
out.groups.push(Group { seq, members });
} else if de.typ == 408 {
if out.instances.len() >= MAX_INSTANCES {
return Err("too many IGES assembly instances".into());
}
let definition = usize::try_from(t.i(seq as i64, 1)?).map_err(|_| "invalid IGES subfigure reference")?;
if t.de(definition as i64)?.typ != 308 {
return Err("IGES instance does not refer to a subfigure definition".into());
}
let p = t.p(seq as i64)?;
let scale = match p.get(5).map(|s| s.trim()) {
None | Some("") => 1.0,
Some(s) => num(s).filter(|x| *x != 0.0).ok_or("invalid IGES instance scale")?,
};
let [x, y, z] = t.pt(seq as i64, 2)?;
let local = Xf([[scale, 0.0, 0.0, x], [0.0, scale, 0.0, y], [0.0, 0.0, scale, z]]);
let transform = t.own_xf(seq as i64, 0)?.then(&local).then(&t.own_xf(definition as i64, 0)?);
if !transform.0.iter().flatten().all(|v| v.is_finite()) {
return Err("non-finite IGES instance placement".into());
}
out.instances.insert(seq, Instance { definition, transform });
out.referenced.insert(definition);
}
}
// Index geometry references once instead of rescanning all parameters for each patch.
for (&seq, params) in &t.f.params {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
if t.f.des.get(&seq).is_some_and(|d| matches!(d.typ, 308 | 402 | 406 | 408)) {
continue;
}
for p in params.iter().skip(1) {
let p = p.trim();
if p.starts_with('0') {
continue;
}
if let Ok(id) = p.parse::<usize>()
&& id != seq
&& t.f.des.get(&id).is_some_and(|d| d.typ == 128)
{
out.surface_references.insert(id);
}
}
}
let mut depths = HashMap::with_capacity(out.groups.len());
let mut placements = out.groups.iter().filter(|g| !out.referenced.contains(&g.seq)).count()
+ out.instances.keys().filter(|s| !out.referenced.contains(*s)).count();
for group in &out.groups {
placements += group.members.iter().filter(|s| out.indices.contains_key(*s) || out.instances.contains_key(*s)).count();
if placements > MAX_INSTANCES {
return Err("too many IGES assembly placements".into());
}
out.depth(group.seq, 0, &mut depths)?;
}
Ok(out)
}
fn depth(&self, seq: usize, depth: usize, seen: &mut HashMap<usize, Option<usize>>) -> R<usize> {
if depth >= MAX_DEPTH {
return Err("IGES assembly nesting is too deep".into());
}
if let Some(height) = seen.get(&seq) {
return height.ok_or_else(|| "cyclic IGES assembly".into());
}
seen.insert(seq, None);
let group = self.indices.get(&seq).and_then(|i| self.groups.get(*i)).ok_or("missing IGES assembly definition")?;
let mut height = 1;
for child in &group.members {
let definition = self.instances.get(child).map(|i| i.definition).unwrap_or(*child);
if self.indices.contains_key(&definition) {
height = height.max(1 + self.depth(definition, depth + 1, seen)?);
}
}
if height > MAX_DEPTH {
return Err("IGES assembly nesting is too deep".into());
}
seen.insert(seq, Some(height));
Ok(height)
}
pub(super) fn write(&self, t: &mut Tr, seqs: &[usize], solids: &HashMap<usize, u64>, styles: &mut Vec<(u64, [f32; 3])>, ctx: u64) -> R<()> {
let app = t.o.add("APPLICATION_CONTEXT('mechanical design')");
let pc = t.o.add(format!("PRODUCT_CONTEXT('',#{app},'mechanical')"));
let dc = t.o.add(format!("PRODUCT_DEFINITION_CONTEXT('part definition',#{app},'design')"));
let origin = t.o.placement([0.0; 3], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]);
let mut products = HashMap::with_capacity(self.groups.len() + 1);
let mut items = Vec::new();
let mut list = String::new();
let mut fallback = String::new();
let mut made = 0usize;
let f = t.f;
let mut faces = Vec::new();
let mut sewer = Sewer { tol: t.tol, verts: Vec::new(), grid: HashMap::new(), edges: Vec::new(), by_ends: HashMap::new() };
let watch = crate::watch::current();
// A synthetic root holds loose geometry and top-level groups/instances.
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
let seq = group.map(|g| g.seq).unwrap_or(0);
let name = if seq == 0 {
"Import"
} else if let Some(name) = entity_name(f, seq) {
name
} else {
fallback.clear();
let _ = write!(fallback, "Group {seq}");
fallback.as_str()
};
items.clear();
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
items.extend(members.iter().filter(|s| seq != 0 || !self.referenced.contains(*s)).filter_map(|s| solids.get(s).copied()));
let surfaces = members
.iter()
.copied()
.filter(|s| seq != 0 || !self.referenced.contains(s))
.filter(|s| f.des.get(s).is_some_and(|d| matches!(d.typ, 143 | 144) || (d.typ == 128 && !self.surface_references.contains(s))));
if let Some(item) = sew_surfaces(t, surfaces, if seq == 0 { "Body1" } else { name }, styles, &mut faces, &mut sewer)? {
items.push(item);
}
made += items.len();
list.clear();
let _ = write!(list, "#{origin}");
for item in &items {
let _ = write!(list, ",#{item}");
}
let rep = t.o.add(format!("ADVANCED_BREP_SHAPE_REPRESENTATION('',({list}),#{ctx})"));
let name = name.replace('\'', "''");
let product = t.o.add(format!("PRODUCT('{name}','{name}','',(#{pc}))"));
let formation = t.o.add(format!("PRODUCT_DEFINITION_FORMATION('','',#{product})"));
let pd = t.o.add(format!("PRODUCT_DEFINITION('design','',#{formation},#{dc})"));
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{pd})"));
t.o.add(format!("SHAPE_DEFINITION_REPRESENTATION(#{shape},#{rep})"));
products.insert(seq, (pd, rep));
}
if made == 0 {
let why = t.warnings.first().map(String::as_str).unwrap_or("no solids or trimmed surfaces");
return Err(format!("IGES import: nothing to read ({why})"));
}
for group in std::iter::once(None).chain(self.groups.iter().map(Some)) {
let parent = group.map(|g| g.seq).unwrap_or(0);
let members = group.map(|g| g.members.as_slice()).unwrap_or(seqs);
for &child in members {
if watch.as_ref().is_some_and(|w| w.cancelled()) {
return Err("IGES import cancelled".into());
}
if parent == 0 && self.referenced.contains(&child) {
continue;
}
let (definition, transform) = match self.instances.get(&child) {
Some(i) => (i.definition, i.transform),
None if self.indices.contains_key(&child) => (child, t.own_xf(child as i64, 0)?),
_ => continue,
};
let (parent_pd, parent_rep) = products.get(&parent).copied().ok_or("missing IGES assembly parent")?;
let (child_pd, child_rep) = products.get(&definition).copied().ok_or("missing IGES assembly child")?;
let label = entity_name(f, child).unwrap_or("").replace('\'', "''");
let nauo = t.o.add(format!("NEXT_ASSEMBLY_USAGE_OCCURRENCE('{child}','{label}','',#{parent_pd},#{child_pd},$)"));
let shape = t.o.add(format!("PRODUCT_DEFINITION_SHAPE('','',#{nauo})"));
let op = transformation(&mut t.o, transform)?;
let rr = t.o.add(format!("(REPRESENTATION_RELATIONSHIP('','',#{child_rep},#{parent_rep})REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION(#{op})SHAPE_REPRESENTATION_RELATIONSHIP())"));
t.o.add(format!("CONTEXT_DEPENDENT_SHAPE_REPRESENTATION(#{rr},#{shape})"));
}
}
Ok(())
}
}
fn transformation(out: &mut Out, xf: Xf) -> R<u64> {
if !xf.0.iter().flatten().all(|v| v.is_finite()) {
return Err("non-finite IGES assembly placement".into());
}
let [x, y, z] = [xf.dir([1.0, 0.0, 0.0]), xf.dir([0.0, 1.0, 0.0]), xf.dir([0.0, 0.0, 1.0])];
let [sx, sy, sz] = [norm(x), norm(y), norm(z)];
let scale = xf.scale();
if [sx, sy, sz, scale].iter().any(|s| !s.is_finite() || *s <= 1e-300) {
return Err("singular IGES assembly placement".into());
}
let (x, y, z, o) = (out.dir(mul(x, 1.0 / sx)), out.dir(mul(y, 1.0 / sy)), out.dir(mul(z, 1.0 / sz)), out.point(xf.point([0.0; 3])));
Ok(out.add(format!("CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM('','',#{x},#{y},#{o},{},#{z},{},{})", r(sx), r(sy), r(sz))))
}
/// Name properties follow the associativity pointers; member pointers are not properties.
pub(super) fn entity_name(f: &Iges, seq: usize) -> Option<&str> {
let de = f.des.get(&seq)?;
let p = f.params.get(&seq)?;
let count = |i: usize| p.get(i).and_then(|s| int(s)).and_then(|x| usize::try_from(x).ok()).filter(|n| *n <= MAX_LIST);
let end = match de.typ {
186 => count(3)?.checked_mul(2)?.checked_add(4)?,
308 => count(3)?.checked_add(4)?,
402 => count(1)?.checked_add(2)?,
408 => 6,
_ => p.len(),
};
let property = (|| {
let after = end.checked_add(count(end)?)?.checked_add(1)?;
let n = count(after)?;
p.get(after + 1..after + 1 + n)?.iter().find_map(|s| {
let id = int(s).and_then(|x| usize::try_from(x).ok())?;
let d = f.des.get(&id)?;
if d.typ != 406 || d.form != 15 {
return None;
}
f.params.get(&id)?.get(2).and_then(|s| name_text(s))
})
})();
property.or_else(|| if de.typ == 308 { p.get(2).and_then(|s| name_text(s)) } else { None }).or_else(|| name_text(&de.label))
}
/// Match the component-name limit before copying or repeating a shared name property.
fn name_text(s: &str) -> Option<&str> {
let end = s.char_indices().nth(128).map(|(i, _)| i).unwrap_or(s.len());
let name = s.get(..end)?.trim();
(!name.is_empty()).then_some(name)
}
+6 -197
View File
@@ -156,116 +156,15 @@ fn an_arc_revolved_about_its_centre_is_a_cap_not_a_ball() {
assert!(rel(area, cap) < 1e-3, "area {area} vs {cap}");
}
/// An exported box inside a named group and nested subfigures, placed twice. The second
/// placement composes its translation and scale with a directory transformation.
fn assembly_file(form: i64, scale: f64) -> String {
let body = box_solid(Vec3::ZERO, Vec3::new(2.0, 3.0, 4.0)).unwrap();
let text = iges_export_bodies(&[ExportBody { name: "Body1".into(), body: &body, color: None }], &StepHeader::default()).unwrap();
let f = parse(&text).unwrap();
let mut ids: Vec<_> = f.des.keys().copied().collect();
ids.sort_unstable();
let solid = *ids.iter().find(|&&s| f.des[&s].typ == 186).unwrap();
let mut transforms: HashMap<_, _> = f.des.iter().map(|(seq, d)| (*seq, d.xform)).collect();
let mut ents: Vec<_> = ids
.iter()
.enumerate()
.map(|(k, seq)| {
assert_eq!(*seq, 2 * k + 1);
let d = &f.des[seq];
let p = f.params[seq]
.iter()
.skip(1)
.map(|s| if s.is_empty() || num(s).is_some() { s.clone() } else { format!("{}H{s}", s.chars().count()) })
.collect::<Vec<_>>()
.join(",");
(d.typ, d.form, p)
})
.collect();
let definition = 2 * ents.len() + 1;
ents.push((308, 0, format!("0,8HLead rod,1,{solid}")));
let first = 2 * ents.len() + 1;
ents.push((408, 0, format!("{definition},10.,0.,0.,1.")));
let rotation = 2 * ents.len() + 1;
ents.push((124, 0, "0.,-1.,0.,100.,1.,0.,0.,0.,0.,0.,1.,0.".into()));
let second = 2 * ents.len() + 1;
ents.push((408, 0, format!("{definition},0.,20.,0.,{scale}")));
transforms.insert(second, rotation as i64);
let frame = 2 * ents.len() + 1;
ents.push((308, 0, format!("1,5HFrame,2,{first},{second}")));
let property = 2 * ents.len() + 1;
ents.push((406, 15, "1,6HMotion".into()));
ents.push((402, form, format!("1,{frame},0,1,{property}")));
iges_file(&ents)
.lines()
.map(|line| {
if line.as_bytes()[72] != b'D' {
return line.to_string();
}
let seq: usize = line[73..80].trim().parse().unwrap();
match transforms.get(&seq) {
Some(xf) => format!("{}{:>8}{}", &line[..48], xf, &line[56..]),
None => line.to_string(),
}
})
.collect::<Vec<_>>()
.join("\n")
}
/// A unit-cube-like box (10 × 20 × 30) made of six trimmed bilinear B-spline patches whose
/// boundaries are lines: sewn into a closed solid.
#[test]
fn groups_and_subfigure_instances_keep_names_structure_and_placements() {
for (form, scale) in [(1, 1.0), (7, 2.0), (14, 1.0), (15, 2.0)] {
let imp = iges_import(&assembly_file(form, scale)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
assert_eq!(imp.tree.len(), 1);
let motion = &imp.tree[0].children[0];
assert_eq!(motion.name, "Motion");
let frame = &motion.children[0];
assert_eq!(frame.name, "Frame");
assert_eq!(frame.children.len(), 2);
assert!(frame.children.iter().all(|n| n.name == "Lead rod"));
assert!(imp.bodies.iter().all(|b| b.path == ["Import", "Motion", "Frame", "Lead rod"]));
assert_eq!(imp.bodies[0].source_item, imp.bodies[1].source_item);
let a = measure(&imp.bodies[0].body).unwrap();
let b = measure(&imp.bodies[1].body).unwrap();
assert!(rel(a.volume, 24.0) < 1e-6 && rel(b.volume, 24.0 * scale.powi(3)) < 1e-6);
assert!(a.bbox.min.dist(Vec3::new(10.0, 0.0, 0.0)) < 1e-6 && a.bbox.max.dist(Vec3::new(12.0, 3.0, 4.0)) < 1e-6);
assert!(b.bbox.min.dist(Vec3::new(80.0 - 3.0 * scale, 0.0, 0.0)) < 1e-6);
assert!(b.bbox.max.dist(Vec3::new(80.0, 2.0 * scale, 4.0 * scale)) < 1e-6);
}
}
#[test]
fn invalid_assembly_references_and_counts_fail_before_geometry() {
for (params, message) in [
("0,4HLoop,1,1".into(), "cyclic"),
("0,4HPart,1,999".into(), "missing entity"),
("0,4HPart,-1".into(), "negative IGES group size"),
(format!("0,4HPart,{}", MAX_LIST + 1), "work limit"),
] {
let error = iges_import(&iges_file(&[(308, 0, params)])).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains(message)), "{error}");
}
let mut chain = Vec::new();
for k in 0..=MAX_DEPTH {
chain.push((308, 0, if k == MAX_DEPTH { "0,4HPart,0".into() } else { format!("0,4HPart,1,{}", 2 * k + 3) }));
}
let error = iges_import(&iges_file(&chain)).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("nesting is too deep")), "{error}");
let members = "1,".repeat(100_000);
let many = [(308, 0, "0,4HPart,0".into()), (308, 0, format!("1,5HFrame,100001,{members}1"))];
let error = iges_import(&iges_file(&many)).unwrap_err();
assert!(matches!(&error, crate::KernelError::Invalid(text) if text.contains("too many IGES assembly placements")), "{error}");
}
/// Six trimmed bilinear patches with line boundaries, forming a 10 × 20 × 30 box.
fn trimmed_box(ents: &mut Vec<(i64, i64, String)>, x: f64) -> Vec<usize> {
let c = |i: usize| -> [f64; 3] {
[x + if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }]
};
fn trimmed_surfaces_sew_into_a_solid() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
// Faces as corner quads (counter-clockwise seen from outside).
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let mut faces = Vec::new();
let mut ents: Vec<(i64, i64, String)> = Vec::new();
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
for q in quads {
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
@@ -283,15 +182,7 @@ fn trimmed_box(ents: &mut Vec<(i64, i64, String)>, x: f64) -> Vec<usize> {
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
faces.push(2 * ents.len() - 1);
}
faces
}
#[test]
fn trimmed_surfaces_sew_into_a_solid() {
let mut ents = Vec::new();
trimmed_box(&mut ents, 0.0);
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
@@ -299,49 +190,6 @@ fn trimmed_surfaces_sew_into_a_solid() {
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "{} {}", m.volume, m.area);
}
/// Trim pieces whose ends miss each other by a hundredth of a millimetre, far past the file's
/// stated resolution: the loop is closed and the box sews into a solid. A gap of half a
/// millimetre is no corner written loosely, and stays open.
#[test]
fn trim_loops_with_small_gaps_still_close() {
let with_gap = |gap: f64| {
let mut ents = Vec::new();
trimmed_box(&mut ents, 0.0);
// The edge from (0, 0, 0) towards (0, 20, 0) stops `gap` short of that corner, in both
// faces that run along it (the first face's first line, the fifth face's last).
ents[1].2 = format!("0.,0.,0.,0.,{:?},0.", 20.0 - gap);
ents[36].2 = format!("0.,{:?},0.,0.,0.,0.", 20.0 - gap);
iges_import(&iges_file(&ents))
};
let imp = with_gap(0.01).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert!(imp.bodies[0].closed);
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-3 && rel(m.area, 2200.0) < 1e-3, "{} {}", m.volume, m.area);
if let Ok(imp) = with_gap(0.5) {
assert!(imp.bodies.iter().all(|b| !b.closed), "a 0.5 mm gap was closed");
}
}
#[test]
fn surfaces_in_separate_groups_remain_separate_parts() {
let mut ents = Vec::new();
for (name, x) in [("Left", 0.0), ("Right", 100.0)] {
let faces = trimmed_box(&mut ents, x);
let members = faces.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(",");
ents.push((308, 0, format!("0,{}H{name},{},{members}", name.len(), faces.len())));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "{:?}", imp.warnings);
assert_eq!(imp.bodies.len(), 2);
assert_eq!(imp.tree[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Left", "Right"]);
for (body, x) in imp.bodies.iter().zip([0.0, 100.0]) {
let m = measure(&body.body).unwrap();
assert!(body.closed && rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6);
assert!(m.bbox.min.dist(Vec3::new(x, 0.0, 0.0)) < 1e-6 && m.bbox.max.dist(Vec3::new(x + 10.0, 20.0, 30.0)) < 1e-6);
}
}
#[test]
fn a_drill_point_round_trips_through_iges() {
// A blind hole with a cone tip: the tip's face closes at its apex (its seam runs out to the
@@ -494,42 +342,3 @@ fn a_hole_whose_first_trim_piece_is_reversed_stays_a_hole() {
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.area, 84.0) < 1e-6, "area {}", m.area);
}
/// The box of `trimmed_surfaces_sew_into_a_solid` with one edge written as a B-spline (126)
/// over a knot span of 1e-7 or less, as some exporters write short curves: the STEP side read
/// that span as a single value and refused the curve, and both faces it bounds were lost.
#[test]
fn a_curve_over_a_tiny_knot_span_still_bounds_its_faces() {
let c =
|i: usize| -> [f64; 3] { [if i & 1 != 0 { 10.0 } else { 0.0 }, if i & 2 != 0 { 20.0 } else { 0.0 }, if i & 4 != 0 { 30.0 } else { 0.0 }] };
let quads = [[0, 2, 3, 1], [4, 5, 7, 6], [0, 1, 5, 4], [2, 6, 7, 3], [0, 4, 6, 2], [1, 3, 7, 5]];
let fmt = |p: [f64; 3]| format!("{:?},{:?},{:?}", p[0], p[1], p[2]);
for span in [1e-7f64, 1e-9, 1e-13] {
let mut ents: Vec<(i64, i64, String)> = Vec::new();
for (fi, q) in quads.iter().enumerate() {
let (a, b, cc, d) = (c(q[0]), c(q[1]), c(q[2]), c(q[3]));
let surf = format!("1,1,1,1,0,0,1,0,0,0.,0.,1.,1.,0.,0.,1.,1.,1.,1.,1.,1.,{},{},{},{},0.,1.,0.,1.", fmt(a), fmt(b), fmt(d), fmt(cc));
ents.push((128, 0, surf));
let s_de = 2 * ents.len() - 1;
let mut lines = Vec::new();
for (k, (p0, p1)) in [(a, b), (b, cc), (cc, d), (d, a)].into_iter().enumerate() {
if fi == 0 && k == 0 {
ents.push((126, 0, format!("1,1,0,0,1,0,0.,0.,{span:E},{span:E},1.,1.,{},{},0.,{span:E},0.,0.,1.", fmt(p0), fmt(p1))));
} else {
ents.push((110, 0, format!("{},{}", fmt(p0), fmt(p1))));
}
lines.push(2 * ents.len() - 1);
}
ents.push((102, 0, format!("4,{}", lines.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(","))));
let comp = 2 * ents.len() - 1;
ents.push((142, 0, format!("0,{s_de},0,{comp},2")));
let cos = 2 * ents.len() - 1;
ents.push((144, 0, format!("{s_de},1,0,{cos}")));
}
let imp = iges_import(&iges_file(&ents)).unwrap();
assert!(imp.warnings.is_empty(), "span {span}: {:?}", imp.warnings);
assert!(imp.bodies[0].closed, "span {span}");
let m = measure(&imp.bodies[0].body).unwrap();
assert!(rel(m.volume, 6000.0) < 1e-6 && rel(m.area, 2200.0) < 1e-6, "span {span}: {} {}", m.volume, m.area);
}
}
-2
View File
@@ -153,7 +153,6 @@ mod tspline;
#[cfg(test)]
mod untouched_faces_tests;
pub mod watch;
mod whole_face;
pub use blend::{ChamferSide, chamfer, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners, fillet_max, fillet_max_each};
pub use blend_corner::fillet_radii;
@@ -221,7 +220,6 @@ pub use sweep_path::{RailFit, SweepControl, SweepOrientation, sweep_path, sweep_
pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at};
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
pub use whole_face::{WholeFace, cut_takes_only_the_face, cut_tool, face_prism, matching_face};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
+5 -141
View File
@@ -22,21 +22,15 @@ pub struct BodyMeasure {
pub merged: TopoCounts,
}
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem), taken
/// about `o` (the moments relative to it), and the sum of the area vectors (zero over a closed
/// surface). Over a closed surface the volume does not depend on `o`; over one that leaves a
/// gap, the gap is closed by a cone to `o`, so `o` near the body keeps that cone small.
fn face_sums(m: &Mesh, nf: usize, o: Vec3) -> Vec<(f64, f64, Vec3, Vec3)> {
let mut out = vec![(0.0, 0.0, Vec3::ZERO, Vec3::ZERO); nf];
/// Per-face sums: area, 6 × signed volume and 24 × first moments (divergence theorem, origin 0).
fn face_sums(m: &Mesh, nf: usize) -> Vec<(f64, f64, Vec3)> {
let mut out = vec![(0.0, 0.0, Vec3::ZERO); nf];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let (Some([a, b, c]), Some(slot)) = (m.tri(t), out.get_mut(*f as usize)) else { continue };
let n = (b - a).cross(c - a) * 0.5;
let (a, b, c) = (a - o, b - o, c - o);
let d = a.dot(b.cross(c));
slot.0 += n.len();
slot.0 += (b - a).cross(c - a).len() * 0.5;
slot.1 += d;
slot.2 += (a + b + c) * d;
slot.3 += n;
}
out
}
@@ -158,13 +152,7 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
None => None,
};
let nf = b.face_count();
// About the box's centre when the meshes leave only a crack: see `face_sums`. A body that is
// open on purpose (a surface, or a cup whose lid is still a separate sheet) has no volume of
// its own; it keeps the origin, so such bodies still compare with each other when the centre
// of their boxes moves (a crowned lid rises, the open cup under it doesn't change).
let (surface, gap) = face_sums(&fine, nf, Vec3::ZERO).iter().fold((0.0, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.3));
let o = Some(fine.bounds().center()).filter(|c| c.is_finite() && gap.len() <= 0.01 * surface).unwrap_or(Vec3::ZERO);
let (sf, sm, sc) = (face_sums(&fine, nf, o), face_sums(&medium, nf, o), face_sums(&coarse, nf, o));
let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf));
let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9;
let (mut area, mut v6, mut mom) = (0.0, 0.0, Vec3::ZERO);
for ((f, m), c) in sf.iter().zip(&sm).zip(&sc) {
@@ -180,13 +168,6 @@ fn mass(b: &Body, topology: bool) -> Result<(MassMeasure, Option<Mesh>)> {
v6 += pick.1;
mom += pick.2;
}
// The moments back about the origin. A gap the meshes leave (a crack where a trimmed face
// meets a hole cut into it) is closed by a cone to the box's centre, which keeps it small
// (a crack of 0.3 mm² moved a part ring's volume by 7 % about the origin 1000 mm away; about
// the centre, by 0.1 %). Every body is measured by the same meshes this way, so a body and the
// pieces a boolean cuts it into add up: a closed display plan measured instead would mesh
// some faces more coarsely than the sums do and break that.
let mom = o * (4.0 * v6) + mom;
let volume = v6 / 6.0;
let centroid = if v6.abs() > 1e-12 { mom / (4.0 * v6) } else { fine.bounds().center() };
Ok((MassMeasure { volume, area, centroid, bbox: coarse.bounds().union(&fine.bounds()) }, topo))
@@ -216,121 +197,4 @@ mod tests {
assert!((got - want).abs() < 1e-3 * want, "{got} against {want}");
assert!(measure(&ring).unwrap().merged.faces > 0);
}
/// A 10 mm cube at `at`, each side in n × n squares of two triangles, one face per side;
/// without its first `open` triangles.
fn cube(at: Vec3, n: usize, open: usize) -> Mesh {
let mut m = Mesh::default();
let s = 10.0;
for axis in 0..3 {
for side in [0.0, 1.0] {
for i in 0..n {
for j in 0..n {
let corner = |a: usize, b: usize| {
let mut c = [0.0; 3];
c[axis] = side * s;
c[(axis + 1) % 3] = a as f64 * s / n as f64;
c[(axis + 2) % 3] = b as f64 * s / n as f64;
at + Vec3::new(c[0], c[1], c[2])
};
let k = m.positions.len() as u32;
m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]);
m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] });
m.tri_face.extend([(2 * axis) as u32 + side as u32; 2]);
}
}
}
}
m.triangles.drain(..open);
m.tri_face.drain(..open);
m
}
/// The summed per-face sums of `m` about `o`, with the moments about the origin (as
/// `measure` passes them on).
fn sums(m: &Mesh, o: Vec3) -> (f64, f64, Vec3, Vec3) {
let (area, v6, mom, normal) =
face_sums(m, 6, o).iter().fold((0.0, 0.0, Vec3::ZERO, Vec3::ZERO), |s, f| (s.0 + f.0, s.1 + f.1, s.2 + f.2, s.3 + f.3));
(area, v6, o * (4.0 * v6) + mom, normal)
}
/// A cube 1000 mm from the origin with one 0.5 mm² facet left out: about the box's centre
/// the sums miss a cone of that facet to the centre (0.08 %), where about the origin they are
/// 17 % over. Closed, it measures the same about either.
#[test]
fn a_mesh_left_open_is_measured_about_its_centre() {
let at = Vec3::new(1000.0, 1000.0, 1000.0);
let (closed, open) = (cube(at, 10, 0), cube(at, 10, 1));
let centre = at + Vec3::new(5.0, 5.0, 5.0);
for o in [Vec3::ZERO, centre] {
let (area, v6, mom, normal) = sums(&closed, o);
assert!((area - 600.0).abs() < 1e-9 && (v6 / 6.0 - 1000.0).abs() < 1e-6 && normal.len() < 1e-9);
assert!((mom / (4.0 * v6) - centre).len() < 1e-6 * at.len(), "{o:?}");
}
let (_, v6_origin, _, normal) = sums(&open, Vec3::ZERO);
assert!((normal.len() - 0.5).abs() < 1e-9);
// The facet faces the origin: leaving it out adds its cone to the origin, 167 mm³.
assert!((v6_origin / 6.0 - (1000.0 + 0.5 * 1000.0 / 3.0)).abs() < 1e-3, "{}", v6_origin / 6.0);
let (_, v6, mom, _) = sums(&open, centre);
assert!((v6 / 6.0 - (1000.0 - 0.5 * 5.0 / 3.0)).abs() < 1e-6, "{}", v6 / 6.0);
assert!((mom / (4.0 * v6) - centre).len() < 0.01, "{:?}", mom / (4.0 * v6));
}
// A body's measured volume does not depend on where it sits: the same revolved ring of cones
// and cylinders, moved far from the origin, measures what it measures at the origin.
use crate::{revolve, transform};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
use std::f64::consts::{PI, TAU};
/// Ring cross-section in (radius, height): cones top and bottom, cylinders inside and out.
const PROFILE: [(f64, f64); 6] = [(5.0, 0.0), (9.0, 0.0), (11.0, 2.0), (11.0, 6.0), (8.0, 8.0), (5.0, 8.0)];
/// Pappus: 2π × the section's first moment about the axis.
fn ring_volume() -> f64 {
let mut moment6 = 0.0;
for (i, &(x0, y0)) in PROFILE.iter().enumerate() {
let (x1, y1) = PROFILE[(i + 1) % PROFILE.len()];
moment6 += (x0 + x1) * (x0 * y1 - x1 * y0);
}
TAU * moment6 / 6.0
}
fn revolved(plane: &Plane, pts: &[Vec2]) -> Body {
let region = Region2 { outer: Loop2::polygon(pts), holes: vec![] };
revolve(plane, &[region], Vec2::ZERO, Vec2::Y, TAU).unwrap().pop().unwrap()
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs()
}
#[test]
fn ring_volume_does_not_depend_on_placement() {
let pts: Vec<Vec2> = PROFILE.iter().map(|&(r, z)| Vec2::new(r, z)).collect();
let base = revolved(&Plane::XZ, &pts);
let at_origin = measure(&base).unwrap();
assert!(rel(at_origin.volume, ring_volume()) < 1e-4, "{} vs {}", at_origin.volume, ring_volume());
for d in [100.0, 1000.0, 10000.0] {
for axis in [Vec3::X, Vec3::Y, Vec3::Z, Vec3::new(1.0, 1.0, 1.0)] {
let m = measure(&transform(&base, axis * d, Vec3::ZERO, Vec3::Z, 0.0).unwrap()).unwrap();
assert!(rel(m.volume, at_origin.volume) < 1e-6, "moved {d} along {axis:?}: {} vs {}", m.volume, at_origin.volume);
let off = m.centroid - at_origin.centroid - axis * d;
assert!(off.len() < 1e-6 * d, "centroid moved {d} along {axis:?}: off by {off:?}");
}
}
}
#[test]
fn cylinder_and_cone_volumes_far_from_the_origin() {
let (r, h) = (7.0, 12.0);
// A frustum, radii 7 and 3, height 10: πh/3 (R² + Rr + r²).
let frustum = [Vec2::new(0.0, 0.0), Vec2::new(7.0, 0.0), Vec2::new(3.0, 10.0), Vec2::new(0.0, 10.0)];
let cone_volume = PI * 10.0 / 3.0 * (49.0 + 21.0 + 9.0);
for at in [Vec3::ZERO, Vec3::new(5000.0, -3000.0, 8000.0)] {
let cyl = measure(&cylinder(at, Vec3::Z, r, h).unwrap()).unwrap();
assert!(rel(cyl.volume, PI * r * r * h) < 5e-4, "cylinder at {at:?}: {}", cyl.volume);
let cone = measure(&revolved(&Plane { origin: at, ..Plane::XZ }, &frustum)).unwrap();
assert!(rel(cone.volume, cone_volume) < 5e-4, "frustum at {at:?}: {} vs {cone_volume}", cone.volume);
}
}
}
-2
View File
@@ -104,7 +104,6 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
Ok(Body {
solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())),
size: 0.0,
boundary_tol: None,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: Some(Arc::new(out)),
@@ -204,7 +203,6 @@ impl Body {
Some(Body {
solid: self.solid.clone(),
size: 0.0,
boundary_tol: None,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: Some(Arc::new(out)),
+3 -24
View File
@@ -37,10 +37,6 @@ struct Runtime {
size: f64,
/// The orientation mesh's missing-face count at its tolerance.
coarse_missing: Option<(f64, u64)>,
/// [`Body::boundary_tol`]: the accuracy the file it was read from declares. Written only
/// when there is one, so a body without it is stored as before.
#[serde(default, skip_serializing_if = "Option::is_none")]
boundary_tol: Option<f64>,
}
fn invalid(detail: impl std::fmt::Display) -> KernelError {
KernelError::Invalid(format!("native B-rep: {detail}"))
@@ -70,8 +66,7 @@ pub fn encode_native_body(body: &Body) -> Result<Vec<u8>> {
if body.mesh.is_some() {
return Err(invalid("mesh bodies are source blobs, not B-rep"));
}
let runtime =
Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)), boundary_tol: body.boundary_tol };
let runtime = Runtime { size: body.size, coarse_missing: body.coarse_missing.map(|(tol, n)| (tol, n as u64)) };
let saved = SavedBody { solid: adapter::encode(&body.solid)?, metadata: metadata::encode(body)?, runtime };
// Structural validation does not invoke another geometric reconstruction.
validate_topology(&saved)?;
@@ -103,17 +98,13 @@ pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
let saved: SavedBody = serde_json::from_value(value).map_err(invalid)?;
validate_topology(&saved)?;
let solid = adapter::decode(saved.solid)?;
let Runtime { size, coarse_missing, boundary_tol } = saved.runtime;
if !(size.is_finite() && size >= 0.0)
|| coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64)
|| boundary_tol.is_some_and(|t| !(t.is_finite() && t > 0.0))
{
let Runtime { size, coarse_missing } = saved.runtime;
if !(size.is_finite() && size >= 0.0) || coarse_missing.is_some_and(|(tol, n)| !tol.is_finite() || n > MAX_ITEMS as u64) {
return Err(invalid("runtime values"));
}
let mut body = Body {
solid: Arc::new(solid),
size,
boundary_tol,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: coarse_missing.map(|(tol, n)| (tol, n as usize)),
mesh: None,
@@ -237,18 +228,6 @@ mod tests {
assert!((crate::measure(&a).unwrap().volume - crate::measure(&b).unwrap().volume).abs() < 1e-7);
}
}
/// A body read from a file keeps the accuracy the file declares through the model cache, so
/// a reopened design plans its faces as the import did; one without it is stored as before.
#[test]
fn a_files_declared_accuracy_survives_the_model_cache() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(4., 3., 1.)).unwrap();
let plain = encode_native_body(&cube).unwrap();
assert_eq!(decode_native_body(&plain).unwrap().boundary_tol, None);
let read = cube.with_boundary_tol(Some(1e-5));
let bytes = encode_native_body(&read).unwrap();
assert_ne!(bytes, plain);
assert_eq!(decode_native_body(&bytes).unwrap().boundary_tol, Some(1e-5));
}
#[test]
fn native_spheres_tori_and_open_faces_round_trip_without_conversion() {
let cube = crate::box_solid(Vec3::ZERO, Vec3::new(40., 30., 10.)).unwrap();
+17 -73
View File
@@ -85,13 +85,9 @@ impl NumberedLoop {
NumberedLoop { lp: pieces, from }
}
/// Re-split into exactly `n` segments, other loops split their longest segments in half. A
/// full circle is split once round (one turn, every arc a positive sweep) to follow a lead
/// loop: at the lead's vertex directions `angles` when they go round in order (counter-
/// clockwise, or clockwise when `clockwise`), otherwise from the first of them at the lead's
/// perimeter `fractions` (the share of the lead's length before each vertex; evenly when
/// those are unusable).
pub(crate) fn resample(&self, n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> NumberedLoop {
/// Re-split into exactly `n` segments: a full circle gets breaks at `angles` (or evenly),
/// other loops split their longest segments in half.
pub(crate) fn resample(&self, n: usize, angles: &[f64]) -> NumberedLoop {
let lp = &self.lp;
let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))
&& lp.segs.windows(2).all(|w| match (w.first(), w.get(1)) {
@@ -100,15 +96,22 @@ impl NumberedLoop {
}
_ => false,
});
if circle
&& n > 0
&& let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied()
{
let a = circle_breaks(n, angles, fractions, clockwise);
let segs: Vec<Seg2> = (0..a.len())
if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() {
let mut a: Vec<f64> =
if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() };
// Keep the given order but make them increase.
for i in 1..a.len() {
let Some(prev) = a.get(i - 1).copied() else { continue };
if let Some(x) = a.get_mut(i) {
while *x <= prev {
*x += std::f64::consts::TAU;
}
}
}
let segs: Vec<Seg2> = (0..n)
.filter_map(|i| {
let s = *a.get(i)?;
let e = if i + 1 < a.len() { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
let e = if i + 1 < n { *a.get(i + 1)? } else { a.first()? + std::f64::consts::TAU };
Some(Seg2::Arc { center, radius, start: s, sweep: e - s })
})
.collect();
@@ -187,65 +190,6 @@ impl NumberedRegion {
}
}
/// The smallest arc a circle is split into (radians).
const MIN_SWEEP: f64 = 1e-9;
/// `n` increasing break angles of one turn round a circle (each step and the closing step back
/// to the first, one turn on, positive) for a circle following a lead loop: `angles` (the lead's
/// vertex directions) when they go round once in order in the lead's direction (counter-clockwise,
/// or clockwise when `clockwise`), otherwise from the first of them at the lead's perimeter
/// `fractions`, or evenly. A non-star-shaped lead's directions turn back on themselves: forcing
/// them to increase wound the circle round many times (#2570).
fn circle_breaks(n: usize, angles: &[f64], fractions: &[f64], clockwise: bool) -> Vec<f64> {
use std::f64::consts::TAU;
let sign = if clockwise { -1.0 } else { 1.0 };
// Angles measured in the lead's direction of travel.
let along: Vec<f64> = angles.iter().map(|a| sign * a).collect();
let run = (along.len() == n).then(|| one_turn(&along)).flatten().unwrap_or_else(|| {
let start = along.first().copied().filter(|a| a.is_finite()).unwrap_or(0.0);
let even = || (0..n).map(|i| i as f64 / n as f64).collect::<Vec<f64>>();
let f = if usable_fractions(n, fractions) { fractions.to_vec() } else { even() };
f.iter().map(|f| start + TAU * f).collect()
});
let mut breaks: Vec<f64> = run.into_iter().map(|a| sign * a).collect();
if clockwise {
breaks.reverse();
}
breaks
}
/// `along` unwrapped into one increasing turn (each step the shortest positive one), or `None`
/// when they do not go round once in order.
fn one_turn(along: &[f64]) -> Option<Vec<f64>> {
let mut out: Vec<f64> = Vec::with_capacity(along.len());
for &x in along {
if !x.is_finite() {
return None;
}
let next = match out.last() {
Some(&prev) => {
let step = (x - prev).rem_euclid(std::f64::consts::TAU);
if step <= MIN_SWEEP {
return None;
}
prev + step
}
None => x,
};
out.push(next);
}
let (first, last) = (*out.first()?, *out.last()?);
(last - first < std::f64::consts::TAU - MIN_SWEEP).then_some(out)
}
/// `fractions` are `n` finite, strictly increasing shares of a turn from 0, all under one.
fn usable_fractions(n: usize, fractions: &[f64]) -> bool {
fractions.len() == n
&& fractions.first().is_some_and(|f| f.abs() < MIN_SWEEP)
&& fractions.windows(2).all(|w| matches!(w, [a, b] if b - a > MIN_SWEEP))
&& fractions.last().is_some_and(|f| f.is_finite() && *f < 1.0 - MIN_SWEEP)
}
/// Every number of `sets`, once each, in order.
fn union<'a>(sets: impl IntoIterator<Item = &'a [usize]>) -> Vec<usize> {
sets.into_iter().flatten().copied().collect::<BTreeSet<usize>>().into_iter().collect()
-9
View File
@@ -2126,15 +2126,6 @@ fn transform_matrix_keeping(body: &Body, m: [[f64; 4]; 4], phased: bool) -> Resu
if m.iter().flatten().any(|x| !x.is_finite()) {
return Err(KernelError::Invalid("non-finite transform".into()));
}
// A perspective row or a flattening (from a design file or a command's parameters) leaves
// no solid: refused rather than built into faces that mesh to nonsense.
let scale = (0..3).map(|c| Vec3::new(m[c][0], m[c][1], m[c][2]).len()).fold(0.0, f64::max);
if m[0][3].abs() > 1e-12 || m[1][3].abs() > 1e-12 || m[2][3].abs() > 1e-12 || (m[3][3] - 1.0).abs() > 1e-12 {
return Err(KernelError::Invalid("the transform is not affine: its last row must be 0, 0, 0, 1".into()));
}
if det3(&m).abs() <= 1e-12 * scale.powi(3) {
return Err(KernelError::Invalid("the transform flattens the body: its linear part is singular".into()));
}
if let Some(placed) = body.mesh_transformed(&m) {
let history = crate::history::History::identity(&placed);
return placed.with_history(history, &[body]);
-1
View File
@@ -503,7 +503,6 @@ fn divided(body: &Body, faces: &[usize], other: &[(mt::Edge, Piece)], fr: &Frame
let split = Body {
solid: std::sync::Arc::new(solid),
size: 0.0,
boundary_tol: body.boundary_tol,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: None,
+1 -1
View File
@@ -796,7 +796,7 @@ fn unsupported_shell(m: &str) -> KernelError {
KernelError::Failed(format!("not supported yet: {m}"))
}
pub(crate) fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
fn point_tri(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
let n = (b - a).cross(c - a);
let Some(nn) = n.normalized() else { return f64::INFINITY };
let h = (p - a).dot(nn);
-2
View File
@@ -636,7 +636,6 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result<Body> {
let split = Body {
solid: std::sync::Arc::new(solid),
size: 0.0,
boundary_tol: body.boundary_tol,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: None,
@@ -1174,7 +1173,6 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
let split = Body {
solid: std::sync::Arc::new(solid),
size: 0.0,
boundary_tol: body.boundary_tol,
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: None,
mesh: None,
+3 -107
View File
@@ -53,63 +53,6 @@ fn rid(p: Option<&Param>, what: &str) -> R<u64> {
/// A contractible outer loop whose winding opposes the surface's parameter normal.
/// Samples follow the wire through periodic seams; a loop that winds around a surface is
/// not a disk in parameter space and must retain the file's sense.
/// The point of a cone (a surface revolved from a line that meets its axis) when `ring`, a
/// face's only loop, goes once round the axis without touching it: the face then reaches the
/// point. `None` for any other surface or loop.
fn enclosed_apex(surface: &mt::Surface, ring: &mt::Wire, tol: f64) -> Option<mt::Point3> {
use mt::Transform as _;
let mt::Surface::RevolutedCurve(proc) = surface else { return None };
let rc = proc.entity();
let mt::Curve::Line(line) = rc.entity_curve() else { return None };
let (o, axis) = (rc.origin(), rc.axis().normalize());
// The rotation applied to the revolved surface, applied to its axis and profile too.
let m = proc.transform();
let (o, axis) = (m.transform_point(o), m.transform_vector(axis).normalize());
let (p0, p1) = (m.transform_point(line.0), m.transform_point(line.1));
let radius = |p: mt::Point3| (p - o).cross(axis).magnitude();
let (r0, r1) = (radius(p0), radius(p1));
if !((r0 - r1).abs() > 1e-9 * (r0 + r1)) {
return None;
}
let apex = p0 + (p1 - p0) * (r0 / (r0 - r1));
if !(radius(apex) <= tol.max(1e-9) * 10.0) {
return None;
}
// Once round the axis, staying clear of it.
let x = (p0 - o) - axis * (p0 - o).dot(axis);
let x = if x.magnitude() > tol { x.normalize() } else { return None };
let y = axis.cross(x);
let mut turned = 0.0;
let mut last: Option<f64> = None;
let mut first: Option<f64> = None;
for edge in ring.edge_iter() {
let c = edge.oriented_curve();
let (t0, t1) = c.range_tuple();
for k in 0..16 {
let q = c.subs(t0 + (t1 - t0) * f64::from(k) / 16.0);
if radius(q) <= tol {
return None;
}
let d = q - o;
let ang = d.dot(y).atan2(d.dot(x));
if let Some(l) = last {
let mut step = ang - l;
step -= (step / std::f64::consts::TAU).round() * std::f64::consts::TAU;
turned += step;
} else {
first = Some(ang);
}
last = Some(ang);
}
}
if let (Some(l), Some(f)) = (last, first) {
let mut step = f - l;
step -= (step / std::f64::consts::TAU).round() * std::f64::consts::TAU;
turned += step;
}
((turned.abs() - std::f64::consts::TAU).abs() < 0.1).then_some(apex)
}
fn reversed_outer(surface: &mt::Surface, wire: &mt::Wire, tol: f64) -> bool {
if let mt::Surface::RevolutedCurve(proc) = surface {
let rc = proc.entity();
@@ -194,9 +137,8 @@ impl Builder {
let sense = p.get(4).and_then(Param::as_bool).unwrap_or(true);
let g = geom::curve(cx, rid(p.get(3), "edge geometry")?, 0)?;
let closed = vs_id == ve_id;
let pieces =
guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol, cx.snap).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?;
let pieces = guard("step edge", || geom::edge_pieces(&g, vs.point(), ve.point(), sense, closed, cx.tol).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?;
let n = pieces.len();
let mut out = Vec::with_capacity(n);
let mut cur = vs.clone();
@@ -379,35 +321,6 @@ impl Builder {
surface = guard("step surface orientation", || geom::surface(cx, sid, &extent, &seam, !sense, 0).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?;
}
// A cone face bounded only by a ring round its axis (no vertex at the point, or a
// VERTEX_LOOP there, which has no edges) covers the cone up to its point. Its surface
// was spanned over the ring alone, a sliver at the base; it is spanned to the apex,
// and the ring is joined to the apex by a seam line out and back, as a cone with a
// seam is written.
let (mut wires, mut surface) = (wires, surface);
if e.name() != "FACE"
&& seam.is_empty()
&& let [ring] = wires.as_slice()
&& let Some(apex) = enclosed_apex(&surface, ring, cx.tol)
&& let Some(first) = ring.edge_iter().next()
{
let v = first.front().clone();
let a = mt::Vertex::new(apex);
let line = mt::Edge::new_unchecked(&v, &a, mt::Curve::Line(mt::Line(v.point(), apex)));
let mut to_apex = extent.clone();
to_apex.push(apex);
let seam_line = [v.point(), apex];
let sid = rid(p.get(2), "face geometry")?;
let sense = p.get(3).and_then(Param::as_bool).unwrap_or(true);
let spanned =
guard("step cone to its point", || geom::surface(cx, sid, &to_apex, &seam_line, sense, 0).map_err(crate::KernelError::Failed))
.map_err(|e| e.to_string())?;
let mut edges: Vec<mt::Edge> = ring.edge_iter().cloned().collect();
edges.push(line.clone());
edges.push(line.inverse());
wires = vec![edges.into()];
surface = spanned;
}
// Loops on a surface of revolution start away from the axis: a pole (cone apex, sphere
// pole) has no definite rotation parameter, and the mesher takes the first point's
// parameter as the anchor for the rest of the loop.
@@ -504,7 +417,7 @@ impl Builder {
origin = vec![file_face; patches.len()];
out = patches;
}
let out = super::split::split_bands(out, &mut origin, failed, cx.tol);
let out = super::split::split_bands(out, &mut origin, failed);
Ok((out.into(), closed, read, origin))
}
}
@@ -642,22 +555,5 @@ pub(crate) fn read_item(cx: &Ctx, id: u64) -> R<BrepOut> {
if checked.is_err() {
closed = false;
}
// A void's faces face into the cavity, out of the material: it encloses volume of the
// opposite sign to the outer shell. The ORIENTED_CLOSED_SHELL flag alone doesn't say so:
// some exporters write .F. over a shell already facing into the cavity (#3730), others .T.
// A void enclosing volume of the outer shell's sign is turned inside out.
if e.name() == "BREP_WITH_VOIDS"
&& checked.is_ok()
&& let Some((outer, voids)) = shells.split_first_mut()
&& let Some(outside) = crate::body::shell_volume(outer)
{
for void in voids {
if crate::body::shell_volume(void).is_some_and(|v| v * outside > 0.0) {
for face in void.face_iter_mut() {
face.invert();
}
}
}
}
Ok(BrepOut { solid: mt::Solid::new_unchecked(shells), failed, closed, file_faces, face_origin })
}
+1 -38
View File
@@ -35,7 +35,7 @@ fn extruded_ellipse_seam_uses_profile_angles_for_oblique_sweeps() {
{TAIL}"
);
let ex = p21::parse(&text).unwrap();
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6, snap: 1e-6 };
let cx = Ctx { ex: &ex, len: 1.0, ang: 1.0, tol: 1e-6 };
let origin = mt::Point3::new(10.0, -20.0, 3.0);
let d = mt::Vector3::new(0.5, 0.25, 1.0).normalize();
let point = |angle: f64, h: f64| origin + mt::Vector3::new(9.0 * angle.cos(), 4.0 * angle.sin(), 0.0) + d * h;
@@ -236,40 +236,3 @@ fn hostile_conics_never_panic() {
}
}
}
/// A cone to a point written with no vertex at its point (#258): the cone face's only loop is
/// its base circle, or that circle and a VERTEX_LOOP at the point, which has no edges. It was
/// read as a sliver at the base and the solid came in as a flat disc; the face reaches the
/// point now.
#[test]
fn a_cone_bounded_only_by_its_base_circle_reaches_its_point() {
let (r, h) = (6.0f64, 15.0f64);
for apex_loop in [false, true] {
let mut t = String::from(HEAD);
t += "#1=CARTESIAN_POINT('',(0.,0.,0.));\n#2=DIRECTION('',(0.,0.,-1.));\n#3=DIRECTION('',(1.,0.,0.));\n#4=AXIS2_PLACEMENT_3D('',#1,#2,#3);\n";
t += &format!("#5=CONICAL_SURFACE('',#4,{r:.1},{:.15});\n", (r / h).atan());
t += "#6=DIRECTION('',(0.,0.,1.));\n#7=AXIS2_PLACEMENT_3D('',#1,#6,#3);\n#8=PLANE('',#7);\n";
t += &format!("#9=CIRCLE('',#7,{r:.1});\n#10=CARTESIAN_POINT('',({r:.1},0.,0.));\n#11=VERTEX_POINT('',#10);\n");
t += "#12=EDGE_CURVE('',#11,#11,#9,.T.);\n";
t += "#20=ORIENTED_EDGE('',*,*,#12,.F.);\n#21=EDGE_LOOP('',(#20));\n#22=FACE_OUTER_BOUND('',#21,.T.);\n#23=ADVANCED_FACE('',(#22),#8,.F.);\n";
t += "#30=ORIENTED_EDGE('',*,*,#12,.T.);\n#31=EDGE_LOOP('',(#30));\n#32=FACE_OUTER_BOUND('',#31,.T.);\n";
if apex_loop {
t += &format!(
"#33=CARTESIAN_POINT('',(0.,0.,{h:.1}));\n#34=VERTEX_POINT('',#33);\n#35=VERTEX_LOOP('',#34);\n#36=FACE_BOUND('',#35,.T.);\n"
);
t += "#37=ADVANCED_FACE('',(#32,#36),#5,.T.);\n";
} else {
t += "#37=ADVANCED_FACE('',(#32),#5,.T.);\n";
}
t += "#80=CLOSED_SHELL('',(#23,#37));\n#81=MANIFOLD_SOLID_BREP('Cone',#80);\n";
t += CONTEXT;
t += TAIL;
let imp = step_import(&t).unwrap();
let body = &imp.bodies[0].body;
let m = measure(body).unwrap();
let exact = PI * r * r * h / 3.0;
assert!(rel(m.volume, exact) < 2e-3, "vertex loop {apex_loop}: volume {} vs {exact}", m.volume);
let top = body.tessellate(0.01).unwrap().positions.iter().map(|p| p.z).fold(f64::MIN, f64::max);
assert!((top - h).abs() < 1e-6, "vertex loop {apex_loop}: reaches z = {top}, not {h}");
}
}
+10 -78
View File
@@ -101,7 +101,7 @@ pub(crate) fn point(cx: &Ctx, id: u64) -> R<mt::Point3> {
Ok(mt::Point3::new(g(0)?, g(1)?, g(2)?) * cx.len)
}
pub(super) fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
fn direction(cx: &Ctx, id: u64) -> R<mt::Vector3> {
let e = entity(cx, id)?;
if e.name() != "DIRECTION" {
return Err(format!("#{id}: expected DIRECTION, found {}", e.name()));
@@ -176,32 +176,7 @@ fn knot_vec(mults: Option<&Param>, knots: Option<&Param>, expected: usize) -> R<
if m.len() > expected || m.iter().try_fold(0usize, |a, v| a.checked_add(*v)) != Some(expected) {
return Err("knot multiplicities do not match the control points".into());
}
mt::KnotVec::from_single_multi(spanning_one(k), m).map_err(|e| format!("knot vector: {e}"))
}
/// Shortest parameter range a spline is read with.
const MIN_KNOT_RANGE: f64 = 1.0;
/// `knots` stretched, when they span less than [`MIN_KNOT_RANGE`], to span exactly that from the
/// same first knot. A STEP writer may parameterize a spline over any range (some write a few
/// millionths, as if in metres of a part drawn in millimetres), and the kernel tells parameters
/// apart only to a fixed tolerance (1e-6): over such a range it took distinct knots for one, and
/// trimming an edge to its vertices dropped a control point. Stretching the knots changes the
/// parameters, never the curve (every point is where it was), and nothing read from the file
/// refers to them: edges are trimmed by their vertices.
fn spanning_one(knots: Vec<f64>) -> Vec<f64> {
let (Some(&first), Some(&last)) = (knots.first(), knots.last()) else { return knots };
let range = last - first;
if !(range > 0.0 && range < MIN_KNOT_RANGE) {
return knots;
}
let scale = MIN_KNOT_RANGE / range;
let mut out: Vec<f64> = knots.iter().map(|k| first + (k - first) * scale).collect();
// The last knot exactly at the end of the range (the product can miss it by a rounding).
if let Some(k) = out.last_mut() {
*k = first + MIN_KNOT_RANGE;
}
out
mt::KnotVec::from_single_multi(k, m).map_err(|e| format!("knot vector: {e}"))
}
fn generated_knots(kind: &str, n_ctrl: usize, degree: usize) -> R<mt::KnotVec> {
@@ -564,16 +539,15 @@ fn sub_curve<C: Cut + BoundedCurve + Clone>(c: &C, a: f64, b: f64) -> C {
out
}
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge). The vertices may
/// be up to `snap` off the curve; `tol` decides whether the curve closes on itself.
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<C>>
/// Pieces of a spline edge from `ps` to `pe` (two pieces for a closed edge).
fn spline_pieces<C>(c: &C, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<C>>
where
C: ParametricCurve<Point = mt::Point3> + BoundedCurve + SearchNearestParameter<mt::D1, Point = mt::Point3> + Cut + Clone + Invertible,
{
let (t0, t1) = c.range_tuple();
let (ts, ds) = nearest_param(c, ps).ok_or("spline evaluation failed")?;
let (te, de) = nearest_param(c, pe).ok_or("spline evaluation failed")?;
if !(ds <= snap && de <= snap) {
if ds > tol || de > tol {
return Err(format!("edge vertices are {:.3e} mm off their curve", ds.max(de)));
}
let span = t1 - t0;
@@ -615,51 +589,9 @@ where
Ok(pieces)
}
/// Moves one end of a spline piece (`back`: its end, else its start) onto its vertex `p` by
/// the control point that end sits on, so the piece meets the vertex exactly and keeps its
/// shape away from that end. A piece whose end is not a control point (an unclamped spline) is
/// left as it is.
fn pin_end(c: &mut mt::Curve, p: mt::Point3, back: bool) {
let end = if back { c.back() } else { c.front() };
let d = p - end;
if !(d.magnitude2() > 0.0) {
return;
}
let at_end = |q: mt::Point3| q.distance(end) <= mt::TOLERANCE;
match c {
mt::Curve::BSplineCurve(b) => {
let cp = if back { b.control_points_mut().last() } else { b.control_points_mut().next() };
if let Some(q) = cp.filter(|q| at_end(**q)) {
*q += d;
}
}
mt::Curve::NurbsCurve(n) => {
let cp = if back { n.control_points_mut().last() } else { n.control_points_mut().next() };
if let Some(q) = cp.filter(|q| q.w != 0.0 && at_end(mt::Point3::from_homogeneous(**q))) {
*q = (mt::Point3::from_homogeneous(*q) + d).to_homogeneous() * q.w;
}
}
_ => {}
}
}
/// Kernel curves for an edge from `ps` to `pe`: one piece, or two for a closed edge (so no
/// kernel edge starts and ends at the same vertex). A spline or open-conic edge's ends are
/// moved onto vertices up to `snap` off its curve, so the pieces meet the vertices exactly.
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<mt::Curve>> {
let mut pieces = curve_pieces(g, ps, pe, sense, closed, tol, snap)?;
if matches!(g, CurveGeo::BSpline(_) | CurveGeo::Nurbs(_) | CurveGeo::Hyperbola { .. } | CurveGeo::Parabola { .. }) {
if let Some(c) = pieces.first_mut() {
pin_end(c, ps, false);
}
if let Some(c) = pieces.last_mut() {
pin_end(c, pe, true);
}
}
Ok(pieces)
}
fn curve_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64, snap: f64) -> R<Vec<mt::Curve>> {
/// kernel edge starts and ends at the same vertex).
pub(crate) fn edge_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, closed: bool, tol: f64) -> R<Vec<mt::Curve>> {
match g {
CurveGeo::Line { .. } => {
if closed {
@@ -696,15 +628,15 @@ fn curve_pieces(g: &CurveGeo, ps: mt::Point3, pe: mt::Point3, sense: bool, close
}
// The curve is open, so the edge is the parameters between its vertices: rising
// for the same sense, falling for the opposite one.
let (ts, te) = (open_conic_param(g, ps, snap)?, open_conic_param(g, pe, snap)?);
let (ts, te) = (open_conic_param(g, ps, tol)?, open_conic_param(g, pe, tol)?);
let scale = 1.0 + ts.abs().max(te.abs());
if (te - ts).abs() <= 1e-9 * scale {
return Err("degenerate conic edge".into());
}
Ok(vec![open_conic_curve(g, ts, te)?])
}
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol, snap)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol, snap)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
CurveGeo::BSpline(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::BSplineCurve).collect()),
CurveGeo::Nurbs(c) => Ok(spline_pieces(c, ps, pe, sense, closed, tol)?.into_iter().map(mt::Curve::NurbsCurve).collect()),
}
}
+6 -59
View File
@@ -114,9 +114,6 @@ pub(crate) struct Ctx<'a> {
pub ang: f64,
/// Vertex-on-curve tolerance (mm).
pub tol: f64,
/// Farthest an edge's vertex may sit off its curve and still be moved onto it (mm, at least
/// `tol`): see [`snap_limit`].
pub snap: f64,
}
#[derive(Clone, Copy)]
@@ -124,29 +121,9 @@ struct Units {
len: f64,
ang: f64,
tol: f64,
snap: f64,
}
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01, snap: 0.01 };
/// Largest tolerance a file can ask for (mm), however coarse the accuracy it declares.
const MAX_TOL: f64 = 1.0;
/// Gap between an edge's vertex and its curve that is still read as the same point, as a share
/// of the model's size: the chord error the kernel's own coarse meshes allow at that size
/// (`Body::size() * 1e-3`), so moving the vertex onto the curve changes the shape by less than
/// those meshes already do.
const SNAP_SHARE: f64 = 1e-3;
/// Farthest a vertex may sit off its edge's curve and be moved onto it (mm): the file's own
/// tolerance `tol`, or up to [`SNAP_SHARE`] of the model's size `extent` (mm) when that is
/// larger. Some exporters write vertices further off than the accuracy they declare (a surface
/// export may declare 0.01 mm and join two curves 0.38 mm apart at a vertex between them).
/// Never more than [`MAX_TOL`], so a far stray point cannot turn the check off.
fn snap_limit(tol: f64, extent: f64) -> f64 {
let share = if extent.is_finite() { extent * SNAP_SHARE } else { 0.0 };
tol.max(share.min(MAX_TOL))
}
const DEFAULT_UNITS: Units = Units { len: 1.0, ang: 1.0, tol: 0.01 };
fn si_prefix(p: Option<&Param>) -> f64 {
match p.and_then(Param::as_enum) {
@@ -231,8 +208,7 @@ fn context_units(ex: &Exchange, ctx: Option<u64>, cache: &mut HashMap<u64, Units
}
}
}
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, MAX_TOL);
u.snap = snap_limit(u.tol, ex.vertex_extent() * u.len);
u.tol = tol_file.filter(|t| t.is_finite() && *t > 0.0).map(|t| (t * 10.0).max(1e-3)).unwrap_or(u.len * 1e-2).clamp(1e-4, 1.0);
cache.insert(id, u);
u
}
@@ -584,41 +560,12 @@ impl Reader {
}
fn placement_matrix(&mut self, item: u64, u: Units) -> R<mt::Matrix4> {
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol, snap: u.snap };
let cx = Ctx { ex: &self.ex, len: u.len, ang: u.ang, tol: u.tol };
let e = geom::entity(&cx, item)?;
if matches!(e.name(), "CARTESIAN_TRANSFORMATION_OPERATOR_3D" | "CARTESIAN_TRANSFORMATION_OPERATOR_3D_NON_UNIFORM") {
if e.name() == "CARTESIAN_TRANSFORMATION_OPERATOR_3D" {
let p = e.params();
let o = geom::point(&cx, p.get(4).and_then(Param::as_ref_id).ok_or("transformation without origin")?)?;
let direction = |i: usize, fallback: mt::Vector3| -> R<mt::Vector3> {
match p.get(i) {
None | Some(Param::Null) => Ok(fallback),
Some(Param::Ref(id)) => geom::direction(&cx, *id),
_ => Err("invalid transformation direction".into()),
}
};
let scale = |i: usize, fallback: f64| -> R<f64> {
match p.get(i) {
None | Some(Param::Null) => Ok(fallback),
Some(v) => v.as_f64().filter(|x| x.is_finite() && *x != 0.0).ok_or_else(|| "invalid transformation scale".into()),
}
};
let sx = scale(5, 1.0)?;
let (sy, sz) = (scale(7, sx)?, scale(8, sx)?);
let m = mt::Matrix4::from_cols(
(direction(2, mt::Vector3::unit_x())? * sx).extend(0.0),
(direction(3, mt::Vector3::unit_y())? * sy).extend(0.0),
(direction(6, mt::Vector3::unit_z())? * sz).extend(0.0),
o.to_homogeneous(),
);
if !to_array(&m).iter().flatten().all(|x| x.is_finite()) {
return Err("invalid transformation scale".into());
}
use mt::SquareMatrix as _;
let det = m.determinant();
if !det.is_finite() || det == 0.0 {
return Err("singular transformation".into());
}
return Ok(m);
return Ok(mt::Matrix4::from_translation(o.to_homogeneous().truncate()));
}
Ok(geom::frame(&cx, item)?.matrix())
}
@@ -785,7 +732,7 @@ impl Reader {
};
let placement = self.batches.get(p.batch).map_or(mt::Matrix4::from_scale(1.0), |b| b.m);
sources.push((i, item));
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item), tol: units.tol });
jobs.push(parallel::Job { source, placement, check_mesh: !self.unmeshed_checked.contains(&item) });
}
// Reading and orienting new parts is shared out between the workers.
let prepared = parallel::bodies(&ex, &jobs, self.workers);

Some files were not shown because too many files have changed in this diff Show More