mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 13:01:52 +00:00
Merge #8526 (brett19/solidcore e30cfd2ac): solidcore as the default kernel, C++ SMLib behind --features smlib
Three conflicts: - crates/smlib flat.rs and export.rs: #8526's export copies the C++ arrays by id range; the ranges now run to the ball-patch arrays (FACE_DEFINITION_REAL_COUNT, FACE_DEFINITION_REALS), with WIRE_EDGE_NURBS_FORM (35) and FACE_DEFINITION_REAL_COUNT (36) both kept. - tests/KNOWN_FAILING_TESTS: solidcore's lines and develop's, each test once; tests/KNOWN_FAILING_TESTS.smlib takes develop's SMLib a18bbe5 lines too. The first full run trims both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
This commit is contained in:
+73
-36
@@ -442,9 +442,6 @@ jobs:
|
||||
with:
|
||||
key: quick-compile
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-targets --locked
|
||||
|
||||
@@ -464,9 +461,6 @@ jobs:
|
||||
with:
|
||||
key: native-clippy
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Clippy
|
||||
env:
|
||||
FULL: ${{ needs.changes.outputs.full }}
|
||||
@@ -476,6 +470,13 @@ jobs:
|
||||
scope=(--workspace)
|
||||
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
|
||||
cargo clippy "${scope[@]}" --all-targets --profile ci --locked -- -D warnings
|
||||
# The C++ SMLib kernel is opt-in (`--features smlib`, off by default): lint the code and tests
|
||||
# behind it too, on the crates that have any. Needs the prebuilt library.
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Clippy with the C++ SMLib kernel
|
||||
run: cargo clippy -p solidcraft-smlib -p solidcraft-kernel -p solidcraft-cli --features solidcraft-cli/smlib --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
build-tests:
|
||||
name: Build tests
|
||||
@@ -510,9 +511,6 @@ jobs:
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -584,9 +582,6 @@ jobs:
|
||||
with:
|
||||
key: native-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -640,6 +635,69 @@ jobs:
|
||||
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
|
||||
retention-days: 7
|
||||
|
||||
cpp-tests:
|
||||
name: Native Linux tests (C++ SMLib)
|
||||
# The tests that compare solidcore with the C++ SMLib kernel (parity tests, the C++ round trips,
|
||||
# the C++ SSI corpus score, the model cache across kernels) are built only with the opt-in
|
||||
# `smlib` feature, which every other job leaves off. They live in solidcraft-smlib,
|
||||
# solidcraft-kernel and the CLI's model_cache; the other tests there run again here on
|
||||
# solidcore, the default kernel either way, graded against the same list.
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.native == 'true' }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
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: Size parallel compiles to the job's memory
|
||||
run: scripts/ci/build-jobs.sh
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Mount the compiler cache
|
||||
uses: useblacksmith/stickydisk@3f6be1451e3cad893b778bc93495c80452a5da26 # v1.7.2
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: ${{ github.repository }}-sccache-cpp-tests
|
||||
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-cpp-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- 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
|
||||
- name: Run the tests with the C++ kernel built
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
# The pull request filter of Native Linux tests (default() in .config/nextest.toml);
|
||||
# elsewhere everything but the random boolean property tests, as there.
|
||||
base='default()'
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
base='not (package(solidcraft-kernel) & binary(boolean_props))'
|
||||
fi
|
||||
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
|
||||
rm -f "$junit"
|
||||
status=0
|
||||
cargo nextest run -p solidcraft-smlib -p solidcraft-kernel -p solidcraft-cli --features solidcraft-cli/smlib \
|
||||
--cargo-profile ci --locked --profile ci --no-tests=pass --ignore-default-filter -E "($base) & (not package(=solidcraft-cli) | binary(=model_cache))" || status=$?
|
||||
if [ "$status" -ne 0 ] && [ "$status" -ne 100 ]; then exit "$status"; fi
|
||||
cargo xtask known-failing --junit "$junit"
|
||||
- name: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
gpu-path-tests:
|
||||
name: GPU path tests (${{ matrix.shard }}/4)
|
||||
# The GPU path tracer's tests (about 108) compile their pipelines on lavapipe, one test at a
|
||||
@@ -703,9 +761,6 @@ jobs:
|
||||
with:
|
||||
key: native-gpu
|
||||
save-if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop') && matrix.shard == 1 }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -862,9 +917,6 @@ jobs:
|
||||
with:
|
||||
key: doc-tests
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -923,9 +975,6 @@ jobs:
|
||||
with:
|
||||
key: oracle
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
@@ -965,9 +1014,6 @@ jobs:
|
||||
with:
|
||||
key: windows-fxc
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
# One test at a time: WARP's shader JIT crashes now and then (a null read in
|
||||
# d3d10warp.dll's JIT optimizer, about 1 run in 60) when several devices compile compute
|
||||
# pipelines at once, which the parallel tests do. Minidumps showed it in WARP itself, not
|
||||
@@ -992,9 +1038,6 @@ jobs:
|
||||
with:
|
||||
key: windows-startup
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Backend order and probing on Windows
|
||||
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests native_frame::tests
|
||||
- name: Build the app
|
||||
@@ -1012,7 +1055,7 @@ jobs:
|
||||
# commit's real run instead: the merge queue reads `ci` from the newest run of a commit, so a
|
||||
# run without a passing `ci` would block a pull request that passed (#1850, #3182, #3252).
|
||||
name: ci
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
|
||||
needs: [changes, checks, quick-compile, clippy, build-tests, tests, cpp-tests, gpu-path-tests, doc-tests, oracle, windows-fxc, windows-startup, dependency-advisories, wasm]
|
||||
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
@@ -1060,6 +1103,7 @@ jobs:
|
||||
BUILD: ${{ needs.build-tests.result }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
TESTS: ${{ needs.tests.result }}
|
||||
CPP_TESTS: ${{ needs.cpp-tests.result }}
|
||||
GPU_RUN: ${{ needs.changes.outputs.gpu }}
|
||||
GPU: ${{ needs.gpu-path-tests.result }}
|
||||
DOCS: ${{ needs.doc-tests.result }}
|
||||
@@ -1084,6 +1128,7 @@ jobs:
|
||||
test "$BUILD" = success
|
||||
fi
|
||||
test "$TESTS" = success
|
||||
test "$CPP_TESTS" = success
|
||||
fi
|
||||
# The same output decides whether the GPU path tests run (their job's `if`).
|
||||
if [ "$GPU_RUN" = true ]; then
|
||||
@@ -1132,9 +1177,6 @@ jobs:
|
||||
with:
|
||||
key: wasm
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
|
||||
- run: cargo xtask wasm
|
||||
|
||||
riscv64-gates:
|
||||
@@ -1157,9 +1199,6 @@ jobs:
|
||||
with:
|
||||
key: riscv64
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh riscv64gc-unknown-linux-gnu
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
# Ubuntu's regular archive serves amd64; RISC-V packages live in ports.
|
||||
@@ -1180,8 +1219,6 @@ jobs:
|
||||
- name: Cargo check on RISC-V
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
# SMLib has no prebuilt library for RISC-V: its sources are compiled with this.
|
||||
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: 1
|
||||
|
||||
@@ -53,9 +53,6 @@ jobs:
|
||||
with:
|
||||
key: docker-web
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
|
||||
- name: Install trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
@@ -29,9 +29,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Build the CLI
|
||||
run: cargo build --profile ci -p solidcraft-cli
|
||||
- name: Import and check every entry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# The fillet rig as a regression gate: a pull request that touches fillet code (the paths below)
|
||||
# runs every case in tests/kernel-cases/fillets/ on SMLib, through one release build of
|
||||
# runs every case in tests/kernel-cases/fillets/ on solidcore, through one release build of
|
||||
# solidcraft-cli, and fails on a regression (a case that fails and
|
||||
# tests/kernel-cases/fillets/KNOWN_FAILING does not list) or on a listed case that now passes
|
||||
# tests/kernel-cases/fillets/KNOWN_FAILING.solidcore does not list) or on a listed case that now passes
|
||||
# (it is fixed: remove its line, so the list only shrinks). See tests/kernel-cases/README.md.
|
||||
#
|
||||
# GitHub filters a workflow by whole files, so the fillet parts of crates/doc/src/eval*.rs and
|
||||
@@ -86,20 +86,17 @@ jobs:
|
||||
with:
|
||||
key: fillet-rig
|
||||
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Set up compiler cache
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.0
|
||||
- name: Build the CLI (release, SMLib)
|
||||
run: cargo build --release --locked -p solidcraft-cli --features smlib
|
||||
- name: Build the CLI (release, solidcore alone)
|
||||
run: cargo build --release --locked -p solidcraft-cli
|
||||
- name: Run the fillet cases against the known-failing list
|
||||
run: >-
|
||||
cargo run -q --locked -p xtask -- kernel-cases --area fillets
|
||||
--cli target/release/solidcraft-cli --out "$RUNNER_TEMP/fillet-rig"
|
||||
--known-failing tests/kernel-cases/fillets/KNOWN_FAILING
|
||||
--known-failing tests/kernel-cases/fillets/KNOWN_FAILING.solidcore
|
||||
- name: Keep the results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
|
||||
@@ -48,9 +48,6 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: generated-files
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: THIRD-PARTY-LICENSES.txt
|
||||
run: cargo xtask licences
|
||||
- name: docs/features.md
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
# matrix of shards as an artifact, one per selected area plus the ladder, in parallel, so the
|
||||
# wall time is the build plus the slowest shard. See tests/kernel-cases/README.md.
|
||||
#
|
||||
# - An area fails on a regression (a case that fails and its tests/kernel-cases/<area>/KNOWN_FAILING
|
||||
# does not list) or on a listed case that passes (it is fixed: remove its line in the same pull
|
||||
# - An area, on solidcore, fails on a regression (a case that fails and its
|
||||
# tests/kernel-cases/<area>/KNOWN_FAILING.solidcore does not list) or on a listed case that passes (it is fixed: remove its line in the same pull
|
||||
# request, so the list only shrinks).
|
||||
# - The ladder fails on any case whose status or counts differ from
|
||||
# tests/kernel-cases/ladder/golden.json; a right change is re-recorded (`--record "why"`).
|
||||
@@ -199,9 +199,11 @@ jobs:
|
||||
SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
if [ "$SHARD" = ladder ]; then
|
||||
set -- --ladder --gate
|
||||
# The golden is the C++ kernel's: solidcore differs on one case (shell under mitred
|
||||
# rounds), so this shard stays on it until a solidcore golden is justified.
|
||||
set -- --ladder --gate --kernel smlib
|
||||
else
|
||||
set -- --area "$SHARD" --known-failing "tests/kernel-cases/$SHARD/KNOWN_FAILING"
|
||||
set -- --area "$SHARD" --known-failing "tests/kernel-cases/$SHARD/KNOWN_FAILING.solidcore"
|
||||
fi
|
||||
target/debug/xtask kernel-cases "$@" --cli target/release/solidcraft-cli --out "$RUNNER_TEMP/kernel-rig"
|
||||
- name: Keep the results
|
||||
@@ -209,7 +211,7 @@ jobs:
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: kernel-rig-results-${{ matrix.shard }}
|
||||
path: ${{ runner.temp }}/kernel-rig/results.json
|
||||
path: ${{ runner.temp }}/kernel-rig/**/results.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
# One check for the whole gate, for branch protection and the merge queue.
|
||||
|
||||
@@ -41,9 +41,6 @@ jobs:
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
|
||||
- name: Install mdbook and trunk
|
||||
run: |
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
|
||||
@@ -126,9 +126,6 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
@@ -168,9 +165,6 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
@@ -395,9 +389,6 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-${{ matrix.arch }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh ${{ matrix.target }}
|
||||
- name: Build
|
||||
run: packaging/macos/package.sh --build-only ${{ matrix.target }}
|
||||
# Not named release-*: the release job collects every release-* artifact as an asset.
|
||||
@@ -496,9 +487,6 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh ${{ matrix.target }}
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -630,9 +618,6 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
|
||||
shell: bash
|
||||
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
|
||||
@@ -810,6 +810,15 @@ in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
|
||||
The glTF test files are built by the tests themselves (crates/io/src/gltf_in_tests.rs,
|
||||
crates/engine/src/tests/gltf_import.rs); no third-party models are committed.
|
||||
|
||||
## Geometry kernel crates
|
||||
|
||||
| Crate | Used for | Licence |
|
||||
|---|---|---|
|
||||
| solidcore (https://github.com/tangency-labs/solidcore, pinned by rev in Cargo.toml) | the Rust port of NVIDIA's SMLib: the second backend behind crates/smlib, which routes an operation to it once it matches the C++ kernel on the kernel-case rig (crates/smlib/src/route.rs) | Apache-2.0 (its NOTICE credits NVIDIA's USD BRep) |
|
||||
|
||||
Linked as a crate; no code from it is copied into SolidCraft. Its licence text and NOTICE go
|
||||
into THIRD-PARTY-LICENSES.txt when `main` regenerates it.
|
||||
|
||||
## FreeCAD and Rhino import library
|
||||
|
||||
FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg)
|
||||
@@ -825,6 +834,7 @@ copied into SolidCraft.
|
||||
| SolidCraft file | Original contribution | Licence |
|
||||
|---|---|---|
|
||||
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solidcraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solidcraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solidcraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
| crates/smlib/src/core/offset.rs (`draft`: the tilted planes and `SmBrep::LocalOperation`, the tail of the capi's `draft_faces`) | ablasik's `draft` in `crates/smlib/src/ffi/rs_ops2.rs` on [ablasik/solidcraft `app/solidcore-backend-on-develop`](https://github.com/ablasik/solidcraft/blob/9c7e8e31565e1823f0591b58e2967351c32af13f/crates/smlib/src/ffi/rs_ops2.rs), adapted to `core::offset`'s errors and checks | MIT OR Apache-2.0 |
|
||||
|
||||
## Vendored third-party code
|
||||
|
||||
|
||||
Generated
+9
@@ -4051,6 +4051,14 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore"
|
||||
version = "1.0.1"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=2dbd88b6e9b858144609690eb2860e0beba27e54#2dbd88b6e9b858144609690eb2860e0beba27e54"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.30.1"
|
||||
@@ -4281,6 +4289,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solidcore",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-profile",
|
||||
"web-time",
|
||||
|
||||
@@ -31,6 +31,11 @@ solidcraft-space-mouse = { path = "crates/space-mouse" }
|
||||
solidcraft-mcp = { path = "crates/mcp" }
|
||||
solidcraft-derive = { path = "crates/derive" }
|
||||
solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
# The Rust port of NVIDIA's SMLib (Apache-2.0), the kernel crates/smlib routes operations to once
|
||||
# they reach parity (crates/smlib/src/route.rs). Pinned; move the rev on its own commit. For a
|
||||
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
|
||||
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "2dbd88b6e9b858144609690eb2860e0beba27e54" }
|
||||
# test tooling: a dev-dependency only (xtask/src/layers.rs)
|
||||
solidcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ name = "solvecraft-cli"
|
||||
path = "src/alias.rs"
|
||||
|
||||
[features]
|
||||
default = ["raytrace-cpu", "smlib", "profile"]
|
||||
default = ["raytrace-cpu", "profile"]
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
|
||||
# Link SMLib into the kernel (solidcraft-engine/smlib; needs scripts/fetch-smlib.sh). SMLib is the
|
||||
# only kernel, so without it the command line models nothing.
|
||||
# Also link the C++ SMLib kernel (solidcraft-engine/smlib), off by default: needs a C++ toolchain
|
||||
# and scripts/fetch-smlib.sh. solidcore, the Rust kernel, is always built and is the default either
|
||||
# way; with this on, SOLIDCRAFT_KERNEL=smlib or the Geometry kernel preference can choose C++.
|
||||
smlib = ["solidcraft-engine/smlib"]
|
||||
# The profiler, compiled in so any build can say where its time goes (`--profile`,
|
||||
# SOLIDCRAFT_PROFILE=1); it records nothing until asked.
|
||||
|
||||
@@ -692,9 +692,12 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>, validation: solidcraft
|
||||
return out;
|
||||
}
|
||||
};
|
||||
if !solidcraft_engine::kernel::smlib_built() {
|
||||
// The C++ kernel is needed only if it is the one asked for.
|
||||
if !solidcraft_engine::kernel::smlib_built()
|
||||
&& solidcraft_engine::kernel::kernel_route::backend() == solidcraft_engine::kernel::kernel_route::Backend::Smlib
|
||||
{
|
||||
out["status"] = json!("unsupported");
|
||||
out["error"] = json!("this build has no SMLib: build solidcraft-cli with --features smlib");
|
||||
out["error"] = json!("this build doesn't include the C++ SMLib kernel; build solidcraft-cli with --features smlib");
|
||||
return out;
|
||||
}
|
||||
// `refused`: only a clean refusal is right; `solid_or_refused`: a clean refusal, or a result
|
||||
|
||||
@@ -386,3 +386,44 @@ fn a_design_saved_before_names_were_pinned_opens_pinned_and_unchanged() {
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// A saved model is its kernel's: the two differ in tolerances and in what they refuse, so a design
|
||||
/// opened on the other kernel is evaluated on it, in both directions; on the kernel that saved it the
|
||||
/// model is taken as it is. A process each, since the kernel is chosen for the process.
|
||||
/// It reaches the C++ kernel on purpose: SOLIDCRAFT_KERNEL=smlib is the option under test.
|
||||
#[cfg(feature = "smlib")]
|
||||
#[test]
|
||||
fn a_model_saved_on_one_kernel_is_evaluated_again_on_the_other() {
|
||||
let cli = || {
|
||||
let mut c = std::process::Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"));
|
||||
c.env_remove("SOLIDCRAFT_NO_MODEL_CACHE");
|
||||
c
|
||||
};
|
||||
// Under the build's own temporary folder: the files are the command line's, not this process's.
|
||||
let temp = |name: &str| Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("model-cache-{}-{name}.solidcraft", std::process::id()));
|
||||
let script = temp("kernels-script").with_extension("json");
|
||||
let recipe = json!({"commands": [
|
||||
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10, "body_name": "Plate"}},
|
||||
{"command": "solid.cylinder", "params": {"diameter": 8, "height": 10, "base": [10, 10, 10], "operation": "join", "targets": ["Plate"]}},
|
||||
]});
|
||||
std::fs::write(&script, recipe.to_string()).unwrap();
|
||||
// Whether opening found a model in the file it may take, whether it took it, and how many
|
||||
// features it evaluated.
|
||||
let open = |design: &Path, kernel: &str| {
|
||||
let out = cli().env("SOLIDCRAFT_KERNEL", kernel).arg("bench-open").arg(design).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
|
||||
let step = |name: &str| lines.iter().find(|l| l["step"] == name).cloned().unwrap();
|
||||
let (parse, evaluated) = (step("parse"), step("evaluate"));
|
||||
(parse["saved_model"].as_bool().unwrap(), evaluated["adopted"].as_bool().unwrap(), evaluated["recomputed"].as_u64().unwrap())
|
||||
};
|
||||
for (saved, other) in [("solidcore", "smlib"), ("smlib", "solidcore")] {
|
||||
let design = temp(&format!("kernels-{saved}"));
|
||||
let out = cli().env("SOLIDCRAFT_KERNEL", saved).arg("run").arg(&script).arg("--save").arg(&design).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(open(&design, saved), (true, true, 0), "saved on {saved}, opened on it");
|
||||
assert_eq!(open(&design, other), (false, false, 2), "saved on {saved}, opened on {other}");
|
||||
std::fs::remove_file(&design).unwrap();
|
||||
}
|
||||
std::fs::remove_file(&script).unwrap();
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@ name = "solidcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[features]
|
||||
default = ["smlib", "profile"]
|
||||
default = ["profile"]
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
# Link SMLib into the kernel (solidcraft-engine/smlib; needs scripts/fetch-smlib.sh
|
||||
# wasm32-unknown-unknown, or the sources and wasi-sdk in WASI_SDK_PATH, crates/smlib/build.rs). SMLib
|
||||
# is the only kernel, so without it the web app models nothing.
|
||||
# Also link the C++ SMLib kernel (solidcraft-engine/smlib), off by default: needs
|
||||
# scripts/fetch-smlib.sh wasm32-unknown-unknown, or the sources and wasi-sdk in WASI_SDK_PATH
|
||||
# (crates/smlib/build.rs). solidcore, the Rust kernel, is always built and is the default either way.
|
||||
smlib = ["solidcraft-engine/smlib"]
|
||||
# The profiler, compiled in so any build can say where its time goes (the Profile operations
|
||||
# preference, SOLIDCRAFT_PROFILE=1, diagnostics.profile); it records nothing until asked.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Links the browser build with an 8 MiB stack: solidcore's kernel algorithms (subdivision,
|
||||
//! intersection, filleting) recurse legitimately, and wasm has no guard page to catch an
|
||||
//! overflow (solidcore's README recommends 8 MiB).
|
||||
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32") {
|
||||
println!("cargo:rustc-link-arg=-zstack-size=8388608");
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@ publish = false
|
||||
default-run = "solidcraft"
|
||||
|
||||
[features]
|
||||
default = ["raytrace-cpu", "bundled-render-library", "smlib", "profile"]
|
||||
default = ["raytrace-cpu", "bundled-render-library", "profile"]
|
||||
bundled-render-library = ["solidcraft-engine/bundled-render-library"]
|
||||
raytrace-cpu = ["solidcraft-engine/raytrace-cpu"]
|
||||
# Link SMLib into the kernel (solidcraft-engine/smlib; needs scripts/fetch-smlib.sh). SMLib is the
|
||||
# only kernel, so without it the app models nothing.
|
||||
# Also link the C++ SMLib kernel (solidcraft-engine/smlib), off by default: needs a C++ toolchain
|
||||
# and scripts/fetch-smlib.sh. solidcore, the Rust kernel, is always built and is the default either
|
||||
# way; with this on, SOLIDCRAFT_KERNEL=smlib or the Geometry kernel preference can choose C++.
|
||||
smlib = ["solidcraft-engine/smlib"]
|
||||
# The profiler, compiled in so any build can say where its time goes (the Profile operations
|
||||
# preference, SOLIDCRAFT_PROFILE=1, diagnostics.profile); it records nothing until asked.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.diagnostics_kernel": "Geometry Kernel",
|
||||
"prefs.geometry_kernel": "Geometry kernel",
|
||||
"prefs.geometry_kernel_environment": "The SOLIDCRAFT_KERNEL environment variable is set; it wins over this setting.",
|
||||
"prefs.geometry_kernel_note": "solidcore, the default, answers every operation. Choose SMLib for the C++ kernel. A solidcore failure is reported, never retried on SMLib.",
|
||||
"prefs.geometry_kernel_unavailable": "SMLib is not available in this build. Operations run on solidcore."
|
||||
},
|
||||
"ar": {
|
||||
"cmd.diagnostics_kernel": "نواة الهندسة",
|
||||
"prefs.geometry_kernel": "نواة الهندسة",
|
||||
"prefs.geometry_kernel_environment": "متغير البيئة SOLIDCRAFT_KERNEL مضبوط، وله الأولوية على هذا الإعداد.",
|
||||
"prefs.geometry_kernel_note": "يجيب solidcore، وهو الافتراضي، عن كل العمليات. اختر SMLib لاستخدام نواة C++. ويُبلَّغ عن إخفاق solidcore ولا تُعاد المحاولة أبدًا على SMLib.",
|
||||
"prefs.geometry_kernel_unavailable": "SMLib غير متاح في هذا الإصدار. تعمل العمليات على solidcore."
|
||||
},
|
||||
"de": {
|
||||
"cmd.diagnostics_kernel": "Geometriekernel",
|
||||
"prefs.geometry_kernel": "Geometriekernel",
|
||||
"prefs.geometry_kernel_environment": "Die Umgebungsvariable SOLIDCRAFT_KERNEL ist gesetzt; sie hat Vorrang vor dieser Einstellung.",
|
||||
"prefs.geometry_kernel_note": "solidcore, die Standardeinstellung, führt alle Vorgänge aus. Wählen Sie SMLib für den C++-Kernel. Ein Fehler in solidcore wird gemeldet, nie mit SMLib wiederholt.",
|
||||
"prefs.geometry_kernel_unavailable": "SMLib ist in diesem Build nicht verfügbar. Vorgänge laufen auf solidcore."
|
||||
},
|
||||
"fr": {
|
||||
"cmd.diagnostics_kernel": "Noyau géométrique",
|
||||
"prefs.geometry_kernel": "Noyau géométrique",
|
||||
"prefs.geometry_kernel_environment": "La variable d’environnement SOLIDCRAFT_KERNEL est définie ; elle l’emporte sur ce réglage.",
|
||||
"prefs.geometry_kernel_note": "solidcore, le réglage par défaut, traite toutes les opérations. Choisissez SMLib pour le noyau C++. Un échec de solidcore est signalé, jamais réessayé avec SMLib.",
|
||||
"prefs.geometry_kernel_unavailable": "SMLib n’est pas disponible dans cette version. Les opérations s’exécutent sur solidcore."
|
||||
},
|
||||
"he": {
|
||||
"cmd.diagnostics_kernel": "ליבת גאומטריה",
|
||||
"prefs.geometry_kernel": "ליבת גאומטריה",
|
||||
"prefs.geometry_kernel_environment": "משתנה הסביבה SOLIDCRAFT_KERNEL מוגדר, והוא גובר על הגדרה זו.",
|
||||
"prefs.geometry_kernel_note": "solidcore, ברירת המחדל, מבצע את כל הפעולות. בחרו ב-SMLib עבור ליבת ה-C++. כשל של solidcore מדווח, ולעולם אינו מנוסה שוב ב-SMLib.",
|
||||
"prefs.geometry_kernel_unavailable": "SMLib אינו זמין בגרסה זו. הפעולות מתבצעות ב-solidcore."
|
||||
},
|
||||
"ja": {
|
||||
"cmd.diagnostics_kernel": "ジオメトリ カーネル",
|
||||
"prefs.geometry_kernel": "ジオメトリ カーネル",
|
||||
"prefs.geometry_kernel_environment": "環境変数 SOLIDCRAFT_KERNEL が設定されているため、この設定より優先されます。",
|
||||
"prefs.geometry_kernel_note": "既定の solidcore がすべての処理を行います。C++ カーネルを使うには SMLib を選択してください。solidcore の失敗は報告され、SMLib で再試行されることはありません。",
|
||||
"prefs.geometry_kernel_unavailable": "このビルドでは SMLib を利用できません。処理は solidcore で実行されます。"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.diagnostics_kernel": "Nhân hình học",
|
||||
"prefs.geometry_kernel": "Nhân hình học",
|
||||
"prefs.geometry_kernel_environment": "Biến môi trường SOLIDCRAFT_KERNEL đã được đặt; biến này được ưu tiên hơn thiết lập này.",
|
||||
"prefs.geometry_kernel_note": "solidcore, mặc định, xử lý mọi thao tác. Chọn SMLib để dùng nhân C++. Lỗi của solidcore được báo cáo, không bao giờ thử lại trên SMLib.",
|
||||
"prefs.geometry_kernel_unavailable": "Bản dựng này không có SMLib. Các thao tác chạy trên solidcore."
|
||||
},
|
||||
"zh": {
|
||||
"cmd.diagnostics_kernel": "几何内核",
|
||||
"prefs.geometry_kernel": "几何内核",
|
||||
"prefs.geometry_kernel_environment": "已设置环境变量 SOLIDCRAFT_KERNEL,它优先于此设置。",
|
||||
"prefs.geometry_kernel_note": "默认的 solidcore 处理所有操作。如需使用 C++ 内核,请选择 SMLib。solidcore 失败时会报告,绝不会改用 SMLib 重试。",
|
||||
"prefs.geometry_kernel_unavailable": "此版本不提供 SMLib。操作在 solidcore 上运行。"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ default = []
|
||||
# Optional authored texture/HDR bytes; the command registry and metadata remain available.
|
||||
bundled-render-library = []
|
||||
raytrace-cpu = ["solidcraft-render/raytrace-cpu"]
|
||||
# Link SMLib into the kernel (solidcraft-kernel/smlib; needs scripts/fetch-smlib.sh).
|
||||
# Also link the C++ SMLib kernel (solidcraft-kernel/smlib, off by default; needs scripts/fetch-smlib.sh).
|
||||
smlib = ["solidcraft-kernel/smlib"]
|
||||
# Turns on the parameter-contract audit recorder (`SOLIDCRAFT_PARAM_AUDIT`, see
|
||||
# `src/schema/record.rs`), for checking a new contract against the whole test suite.
|
||||
|
||||
@@ -55,11 +55,17 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
trace?: path (write the recorded spans as a Chrome trace-event file, for chrome://tracing or Perfetto). Returns what \
|
||||
was recorded before enabled and reset apply: {compiled, enabled, wall_ms, spans, dropped, by_category: [{category, \
|
||||
self_ms, share}], rows: [{category, name, count, total_ms, self_ms, max_ms}], rows_total, trace?}, the rows the most \
|
||||
self time first. Categories: smlib (SMLib's C API calls, timed by the C API), kernel, feature (one per feature \
|
||||
self time first. Categories: solidcore (the kernel's calls), smlib (SMLib's C API calls, timed by the C API, when it is asked for), kernel, feature (one per feature \
|
||||
computed, by its type), doc, command, import (STEP/IGES stages), io. Self time is a span's time less the spans \
|
||||
inside it; total counts its outermost run only. Recording also starts with SOLIDCRAFT_PROFILE=1, the CLI's \
|
||||
--profile and the app's preference; off, it costs nothing measurable. Read-only",
|
||||
),
|
||||
CommandSpec::new("diagnostics.kernel", "Geometry Kernel", kernel).icon("bug_report").noundo().params(
|
||||
"choice?: solidcore | smlib (sets which kernel answers every operation: solidcore, the default = the Rust port; \
|
||||
smlib = the C++ SMLib kernel, refused as not available on a build without it, such as the web app; bodies already \
|
||||
computed keep their results). SOLIDCRAFT_KERNEL, when set, wins over it. Returns {choice, environment}: choice is \
|
||||
the kernel in force, environment whether SOLIDCRAFT_KERNEL chose it",
|
||||
),
|
||||
CommandSpec::new("diagnostics.validation", "Validation", crate::checked!("diagnostics.validation", validation))
|
||||
.icon("bug_report")
|
||||
.noundo()
|
||||
@@ -85,6 +91,29 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
];
|
||||
|
||||
fn kernel(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_kernel::kernel_route as route;
|
||||
if let Some(v) = p.get("choice").filter(|v| !v.is_null()) {
|
||||
route::set_choice(kernel_asked(v, solidcraft_kernel::smlib_built())?);
|
||||
}
|
||||
Ok(json!({ "choice": route::choice().id(), "environment": route::overridden() }))
|
||||
}
|
||||
|
||||
/// The kernel `choice` asks for, on a build that has (`built`) the C++ kernel or doesn't. A choice of
|
||||
/// the wrong type is refused like an unknown one, not read as no choice; `smlib` on a build without
|
||||
/// it is refused too, never answered by the other kernel.
|
||||
pub(crate) fn kernel_asked(choice: &Value, built: bool) -> Result<solidcraft_kernel::kernel_route::Backend> {
|
||||
use solidcraft_kernel::kernel_route::Backend;
|
||||
let asked = choice.as_str().and_then(Backend::from_id).ok_or_else(|| bad("diagnostics.kernel", "`choice` is solidcore or smlib"))?;
|
||||
if asked == Backend::Smlib && !built {
|
||||
return Err(EngineError::Disabled(
|
||||
"diagnostics.kernel".into(),
|
||||
"this build doesn't include the C++ SMLib kernel; build with --features smlib".into(),
|
||||
));
|
||||
}
|
||||
Ok(asked)
|
||||
}
|
||||
|
||||
fn dir(cmd: &str) -> Result<std::path::PathBuf> {
|
||||
crash::default_dir().ok_or_else(|| bad(cmd, "no crash folder on this system"))
|
||||
}
|
||||
|
||||
@@ -1124,8 +1124,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
/// While the profiler records, a STEP import's reply carries its own profile: the import's
|
||||
/// stages and SMLib's calls, as diagnostics.profile reports them.
|
||||
#[cfg(feature = "smlib")]
|
||||
/// stages and the kernel's calls, as diagnostics.profile reports them.
|
||||
#[test]
|
||||
fn a_step_import_reports_its_profile_while_the_profiler_records() {
|
||||
let _serial = crate::schema::diagnostics::PROFILE_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -1144,7 +1143,7 @@ mod tests {
|
||||
let p = &out["profile"];
|
||||
let rows = p["rows"].as_array().unwrap();
|
||||
let has = |cat: &str| rows.iter().any(|r| r["category"] == cat);
|
||||
assert!(has("import") && has("smlib"), "{p}");
|
||||
assert!(has("import") && (has("solidcore") || has("smlib")), "{p}");
|
||||
assert!(p["by_category"].as_array().is_some_and(|c| !c.is_empty()) && p["wall_ms"].as_f64().is_some_and(|w| w > 0.0), "{p}");
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ mod component_clone;
|
||||
mod component_clone_assembly;
|
||||
mod construct;
|
||||
mod delete;
|
||||
mod diagnostics;
|
||||
pub(crate) mod diagnostics;
|
||||
mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
|
||||
@@ -151,6 +151,94 @@ fn diagnostics_profile_reports_commands_features_and_the_kernel() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Tests that set the process-wide kernel choice run one at a time, and put `solidcore` back
|
||||
/// however they end (a failed assertion included).
|
||||
struct KernelChoice(#[allow(dead_code)] std::sync::MutexGuard<'static, ()>);
|
||||
|
||||
static KERNEL_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
impl KernelChoice {
|
||||
fn take() -> Self {
|
||||
KernelChoice(KERNEL_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for KernelChoice {
|
||||
fn drop(&mut self) {
|
||||
solidcraft_kernel::kernel_route::set_choice(solidcraft_kernel::kernel_route::Backend::Solidcore);
|
||||
}
|
||||
}
|
||||
|
||||
/// diagnostics.kernel says the kernel in force and whether the environment chose it, and its
|
||||
/// `choice` sets it (unless SOLIDCRAFT_KERNEL overrides it).
|
||||
///
|
||||
/// The choice is process-wide and the other tests in this binary build bodies meanwhile, so only
|
||||
/// `solidcore` is set through the command; what `smlib` asks for is read from `kernel_asked`.
|
||||
#[test]
|
||||
fn diagnostics_kernel_reports_and_sets_the_choice() {
|
||||
use solidcraft_kernel::kernel_route as route;
|
||||
let _choice = KernelChoice::take();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("diagnostics.kernel", &json!({})).unwrap();
|
||||
let environment = route::overridden();
|
||||
assert_eq!(r, json!({ "choice": route::choice().id(), "environment": environment }));
|
||||
let r = s.execute("diagnostics.kernel", &json!({ "choice": "solidcore" })).unwrap();
|
||||
assert!(environment || r["choice"] == json!("solidcore"), "{r}");
|
||||
assert_eq!(crate::cmd::diagnostics::kernel_asked(&json!("solidcore"), false).unwrap(), route::Backend::Solidcore);
|
||||
assert_eq!(crate::cmd::diagnostics::kernel_asked(&json!("solidcore"), true).unwrap(), route::Backend::Solidcore);
|
||||
assert_eq!(crate::cmd::diagnostics::kernel_asked(&json!("smlib"), true).unwrap(), route::Backend::Smlib);
|
||||
}
|
||||
|
||||
/// `smlib` on a build without the C++ kernel is refused as not available, whichever kernel is in
|
||||
/// force, and leaves the choice alone: never an answer from the other kernel. Built with it, the
|
||||
/// command would move the other tests' bodies, so `kernel_asked` alone is asked.
|
||||
#[test]
|
||||
fn diagnostics_kernel_refuses_smlib_on_a_build_without_it() {
|
||||
let _choice = KernelChoice::take();
|
||||
let refused = crate::cmd::diagnostics::kernel_asked(&json!("smlib"), false).unwrap_err();
|
||||
assert!(matches!(&refused, crate::EngineError::Disabled(cmd, _) if cmd == "diagnostics.kernel"), "{refused}");
|
||||
if !solidcraft_kernel::smlib_built() {
|
||||
let mut s = Session::default();
|
||||
let before = s.execute("diagnostics.kernel", &json!({})).unwrap();
|
||||
assert!(matches!(s.execute("diagnostics.kernel", &json!({"choice": "smlib"})), Err(crate::EngineError::Disabled(..))));
|
||||
assert_eq!(s.execute("diagnostics.kernel", &json!({})).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// An unknown or mistyped choice is an error that changes nothing; no hostile value panics.
|
||||
#[test]
|
||||
fn diagnostics_kernel_refuses_unknown_choices() {
|
||||
let _choice = KernelChoice::take();
|
||||
let mut s = Session::default();
|
||||
let before = s.execute("diagnostics.kernel", &json!({})).unwrap()["choice"].clone();
|
||||
let huge = "solidcore".repeat(100_000);
|
||||
for bad in [
|
||||
json!("truck"),
|
||||
json!("SMLIB"),
|
||||
json!(""),
|
||||
json!(huge),
|
||||
json!("routed"),
|
||||
json!("routed\0"),
|
||||
json!(5),
|
||||
json!(true),
|
||||
json!(["smlib"]),
|
||||
json!({"choice": "smlib"}),
|
||||
] {
|
||||
match s.execute("diagnostics.kernel", &json!({ "choice": bad })) {
|
||||
Err(crate::EngineError::BadParams { .. }) => {}
|
||||
other => panic!("`{}` was not refused: {:?}", bad.as_str().map_or(0, str::len), other.map_err(|e| e.to_string())),
|
||||
}
|
||||
}
|
||||
for v in crate::tests::hostile_values() {
|
||||
for p in [json!({ "choice": v.clone() }), v] {
|
||||
if let Err(crate::EngineError::Internal(_, msg)) = s.execute("diagnostics.kernel", &p) {
|
||||
panic!("diagnostics.kernel {p}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(s.execute("diagnostics.kernel", &json!({})).unwrap()["choice"], before);
|
||||
}
|
||||
|
||||
/// Features are checked once each by default; the command reads and sets the mode, and the mode
|
||||
/// is the user's: it stays when another design is started and when everything is computed again.
|
||||
#[test]
|
||||
|
||||
@@ -109,12 +109,25 @@ fn clamped(p: usize, n: usize, k: &[f64]) -> bool {
|
||||
p >= 1 && k.len() == n + p + 1 && ends(1..=p) && ends(n..=n + p - 1)
|
||||
}
|
||||
|
||||
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside.
|
||||
/// How far past an end of the interval, relative to its size, a parameter is still that end:
|
||||
/// rounding, not a period away.
|
||||
const WRAP_SLACK: f64 = 1e-12;
|
||||
|
||||
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside. A parameter a rounding
|
||||
/// past an end (a pcurve ending on the seam one ulp beyond it) is that end, not moved a period
|
||||
/// to the other one: a plan's node there would put its triangles across the whole period.
|
||||
pub(crate) fn wrap_into(x: f64, (a, b): (f64, f64)) -> f64 {
|
||||
let period = b - a;
|
||||
if (a..=b).contains(&x) || !(period > 0.0) {
|
||||
return x;
|
||||
}
|
||||
let slack = WRAP_SLACK * period.max(a.abs()).max(b.abs());
|
||||
if x > b && x - b <= slack {
|
||||
return b;
|
||||
}
|
||||
if x < a && a - x <= slack {
|
||||
return a;
|
||||
}
|
||||
let w = x - ((x - a) / period).floor() * period;
|
||||
w.clamp(a, b)
|
||||
}
|
||||
@@ -410,6 +423,9 @@ mod tests {
|
||||
}
|
||||
assert_eq!(s.wrap([1.0, 0.5]), [1.0, 0.5]);
|
||||
assert_eq!(s.wrap([1.25, 2.0]), [0.25, 2.0]);
|
||||
// A rounding past an end is that end, not the other one a period away.
|
||||
assert_eq!(s.wrap([1.0 + f64::EPSILON, 0.5]), [1.0, 0.5]);
|
||||
assert_eq!(s.wrap([-1e-17, 0.5]), [0.0, 0.5]);
|
||||
// Projection finds a point near the seam from either side.
|
||||
let p = s.derivs(0.02, 0.5).map(|d| d.0).unwrap_or(Vec3::ZERO);
|
||||
let dom = domain(&s);
|
||||
|
||||
@@ -26,9 +26,9 @@ wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] }
|
||||
[dev-dependencies]
|
||||
image = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
# Headless pictures of real designs (capture tests), built by the real kernel: SMLib
|
||||
# (scripts/fetch-smlib.sh), as in the apps.
|
||||
solidcraft-engine = { workspace = true, features = ["bundled-render-library", "smlib"] }
|
||||
# Headless pictures of real designs (capture tests), built by the real kernel: solidcore, as in the
|
||||
# apps (the C++ SMLib kernel only when the build turns on solidcraft-engine/smlib).
|
||||
solidcraft-engine = { workspace = true, features = ["bundled-render-library"] }
|
||||
solidcraft-io = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
# The HLSL the D3D12 backend compiles (tests check it suits FXC).
|
||||
|
||||
+15
-2
@@ -1215,7 +1215,7 @@ mod tests {
|
||||
let tree: Value = serde_json::from_slice(manifest).unwrap();
|
||||
assert_eq!(serde_json::to_vec(&tree).unwrap(), *manifest);
|
||||
assert_eq!(tree["model"], model.records);
|
||||
assert_eq!(tree.as_object().unwrap().len(), 11, "{:?}", tree.as_object().unwrap().keys().collect::<Vec<_>>());
|
||||
assert_eq!(tree.as_object().unwrap().len(), 12, "{:?}", tree.as_object().unwrap().keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the
|
||||
@@ -1474,7 +1474,7 @@ mod tests {
|
||||
let doc = document();
|
||||
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
|
||||
let original = parts(&bytes);
|
||||
for key in ["format", "producer", "evaluation", "document", "payload"] {
|
||||
for key in ["format", "producer", "evaluation", "kernel", "document", "payload"] {
|
||||
let mut entries = original.clone();
|
||||
let mut manifest: Value = serde_json::from_slice(&entries["cache/model.json"]).unwrap();
|
||||
manifest[key] = json!("unsupported-or-stale");
|
||||
@@ -1548,6 +1548,19 @@ mod tests {
|
||||
manifest.as_object_mut().unwrap().remove("evaluation");
|
||||
entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap());
|
||||
assert!(read_design_geometry(&package(&entries)).is_none());
|
||||
// The kernel that computed it is recorded, and another kernel's model is evaluated again:
|
||||
// the same source gives different bodies on the two (a cache without one is a miss too).
|
||||
use solidcraft_kernel::kernel_route::{Backend, choice};
|
||||
assert_eq!(written(&original)["kernel"], json!(choice().id()));
|
||||
let other = if choice() == Backend::Solidcore { Backend::Smlib } else { Backend::Solidcore };
|
||||
let bytes = with(&[("kernel", other.id())]);
|
||||
assert!(read_design_geometry(&bytes).is_none(), "{}'s cache", other.id());
|
||||
assert_eq!(crate::read_design(&bytes).unwrap().to_json(), doc.to_json());
|
||||
let mut entries = original.clone();
|
||||
let mut manifest = written(&entries);
|
||||
manifest.as_object_mut().unwrap().remove("kernel");
|
||||
entries.insert("cache/model.json".into(), serde_json::to_vec(&manifest).unwrap());
|
||||
assert!(read_design_geometry(&package(&entries)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The model cache: a design's evaluated timeline (every feature's result and the state after
|
||||
//! it, [`SavedModel`]), saved with it so opening need not evaluate it again. Bodies use
|
||||
//! SolidCraft's exact native B-rep encoding (a mesh body, its own triangles); the key ties the
|
||||
//! cache to the exact document, and to the source and platform that computed it
|
||||
//! cache to the exact document, and to the source, platform and geometry kernel that computed it
|
||||
//! (docs/design-container.md).
|
||||
use super::*;
|
||||
use solidcraft_doc::{ModelState, SavedModel};
|
||||
@@ -29,6 +29,12 @@ fn evaluation() -> String {
|
||||
format!("{}/{}-{}", env!("SOLIDCRAFT_EVALUATION_SOURCE"), std::env::consts::ARCH, std::env::consts::OS)
|
||||
}
|
||||
|
||||
/// The geometry kernel that answers in this process: another kernel's cache is not its result (the
|
||||
/// two differ in tolerances and in what they refuse), so a design opened under it evaluates again.
|
||||
fn kernel() -> &'static str {
|
||||
solidcraft_kernel::kernel_route::choice().id()
|
||||
}
|
||||
|
||||
/// A 4 x 4 matrix as its 16 numbers, in storage order.
|
||||
fn matrix(t: &[[f64; 4]; 4]) -> Vec<f64> {
|
||||
t.iter().flatten().copied().collect()
|
||||
@@ -296,6 +302,7 @@ impl Encoded {
|
||||
document: digest(document),
|
||||
evaluation: evaluation(),
|
||||
format: FORMAT,
|
||||
kernel: kernel(),
|
||||
model: records,
|
||||
payload: digest(&data),
|
||||
producer: PRODUCER,
|
||||
@@ -320,6 +327,7 @@ struct Manifest<'a> {
|
||||
document: String,
|
||||
evaluation: String,
|
||||
format: &'a str,
|
||||
kernel: &'a str,
|
||||
model: &'a Value,
|
||||
payload: String,
|
||||
producer: &'a str,
|
||||
@@ -336,14 +344,15 @@ pub fn read_design_geometry(bytes: &[u8]) -> Option<std::sync::Arc<ModelState>>
|
||||
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()?.final_state().ok()
|
||||
}
|
||||
|
||||
/// Whether `manifest` is a model cache of this build's evaluation source for `document`. The
|
||||
/// application version and commit that wrote it are not asked: another version of the same
|
||||
/// Whether `manifest` is a model cache of this build's evaluation source and kernel for `document`.
|
||||
/// The application version and commit that wrote it are not asked: another version of the same
|
||||
/// evaluation source computed the same model.
|
||||
fn current(manifest: &Value, document: &[u8]) -> bool {
|
||||
let is = |key: &str, value: &str| manifest.get(key).and_then(Value::as_str) == Some(value);
|
||||
is("format", FORMAT)
|
||||
&& is("producer", PRODUCER)
|
||||
&& is("evaluation", &evaluation())
|
||||
&& is("kernel", kernel())
|
||||
&& is("document", &digest(document))
|
||||
&& source_trusted(env!("SOLIDCRAFT_EVALUATION_SOURCE"), cfg!(any(test, feature = "trust-unknown-source")))
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Link NVIDIA's SMLib (tangency-labs/smlib) into the kernel; needs a C++ toolchain and
|
||||
# scripts/fetch-smlib.sh (ADR 0002). Without it every geometric operation answers that SMLib
|
||||
# was not built.
|
||||
# Also link NVIDIA's C++ SMLib (tangency-labs/smlib), off by default; needs a C++ toolchain and
|
||||
# scripts/fetch-smlib.sh (ADR 0002). solidcore, the Rust port, answers every operation either way;
|
||||
# without this, asking for the C++ kernel (SOLIDCRAFT_KERNEL=smlib) is refused as not built.
|
||||
smlib = ["solidcraft-smlib/native"]
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -453,6 +453,7 @@ mod imp {
|
||||
)),
|
||||
_ => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
|
||||
},
|
||||
SmError::Unported(why) => KernelError::Invalid(format!("not supported yet: {why}")),
|
||||
e => failed(what, "operation", e),
|
||||
})?;
|
||||
// Checked here unless the evaluation's validation mode leaves it to the feature's end
|
||||
|
||||
@@ -214,6 +214,8 @@ pub use prism_curve::{PlanarPiece, PlanarSpline};
|
||||
pub use prism_measure::{PrismSection, certified_prism_equivalent, certified_prism_section};
|
||||
pub use repair::{Hole, RebuiltFace, RefusedFace, RepairOptions, RepairReport, SewnGap, repair};
|
||||
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching};
|
||||
/// Which kernel the process runs on, solidcore or SMLib (crates/smlib/src/route.rs).
|
||||
pub use solidcraft_smlib::route as kernel_route;
|
||||
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
|
||||
pub use step::step_export;
|
||||
pub use step_in::{
|
||||
|
||||
@@ -108,7 +108,6 @@ struct Regions {
|
||||
touching: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "smlib")]
|
||||
mod sheets {
|
||||
use solidcraft_geom::Loop2;
|
||||
use solidcraft_geom::Vec3;
|
||||
@@ -289,30 +288,6 @@ mod sheets {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
mod sheets {
|
||||
use super::*;
|
||||
|
||||
fn refused<T>() -> Result<T> {
|
||||
Err(KernelError::Failed("not supported yet: planar region booleans run on SMLib (build with the feature `smlib`)".into()))
|
||||
}
|
||||
|
||||
/// A sheet stands for nothing without SMLib.
|
||||
pub(super) enum Sheet {}
|
||||
|
||||
pub(super) fn sheet(_: &[Region2], _: f64) -> Result<Sheet> {
|
||||
refused()
|
||||
}
|
||||
|
||||
pub(super) fn combine(a: &Sheet, _: &Sheet, _: BoolOp) -> Result<Option<Sheet>> {
|
||||
match *a {}
|
||||
}
|
||||
|
||||
pub(super) fn regions(sheet: &Sheet, _: f64) -> Result<Regions> {
|
||||
match *sheet {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used, clippy::panic)]
|
||||
mod planar_tests {
|
||||
|
||||
@@ -302,8 +302,7 @@ pub(crate) fn segment(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> O
|
||||
/// `run` in `plane` exactly as segments: as [`segment`], and a curve of several Bézier spans
|
||||
/// (a chain of a font's curves, a spline through points) as one segment per span, each the
|
||||
/// line, conic or Bézier it is, for a planar region's outline. `None` when the edge doesn't lie
|
||||
/// in a plane parallel to `plane` or isn't read exactly. Only planar sheets read on SMLib use it.
|
||||
#[cfg(feature = "smlib")]
|
||||
/// in a plane parallel to `plane` or isn't read exactly.
|
||||
pub(crate) fn projected(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> Option<Vec<Seg2>> {
|
||||
read(run, plane, tol, work, true)
|
||||
}
|
||||
|
||||
@@ -192,9 +192,10 @@ mod tests {
|
||||
let k = row("kernel", "boolean").unwrap_or_else(|| panic!("{rep:?}"));
|
||||
assert!(k.count >= 1 && row("kernel", "box_solid").is_some(), "{rep:?}");
|
||||
if r.is_ok() {
|
||||
// With SMLib built, its calls are children of the boolean: their time is not its self time.
|
||||
let smlib: std::time::Duration = rep.rows.iter().filter(|r| r.category == "smlib").map(|r| r.total).sum();
|
||||
assert!(smlib > std::time::Duration::ZERO && k.self_time < k.total, "{rep:?}");
|
||||
// The geometry kernel's calls, on whichever answers, are children of the boolean: their
|
||||
// time is not its self time.
|
||||
let calls: std::time::Duration = rep.rows.iter().filter(|r| matches!(r.category, "smlib" | "solidcore")).map(|r| r.total).sum();
|
||||
assert!(calls > std::time::Duration::ZERO && k.self_time < k.total, "{rep:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,12 +540,18 @@ fn offset_surface_cube_import_is_closed_and_editable() {
|
||||
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
|
||||
}
|
||||
|
||||
/// A solid whose B-rep SMLib refuses, its faces too (here every SMLib call: the build has no
|
||||
/// SMLib), is refused with the kernel's reason, naming the solid. It once came in as a mesh of
|
||||
/// its faces, which the planner could still triangulate.
|
||||
/// A solid whose B-rep SMLib refuses, its faces too (here every SMLib call: the C++ kernel is asked
|
||||
/// for, SOLIDCRAFT_KERNEL=smlib, on a build without it), is refused with the kernel's reason, naming
|
||||
/// the solid. It once came in as a mesh of its faces, which the planner could still triangulate.
|
||||
/// On solidcore, the default, the cube imports, so there is nothing to refuse.
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
#[test]
|
||||
fn a_solid_smlib_refuses_with_its_faces_is_refused_not_meshed() {
|
||||
use crate::kernel_route::{Backend, choice};
|
||||
if choice() != Backend::Smlib {
|
||||
eprintln!("skipped: needs SOLIDCRAFT_KERNEL=smlib on a build without the C++ kernel");
|
||||
return;
|
||||
}
|
||||
let text = format!("{HEAD}{}#60=SHAPE_REPRESENTATION('',(#101),$);\n{TAIL}", cube_entities());
|
||||
let why = step_import(&text).map(|imp| imp.warnings).unwrap_err().to_string();
|
||||
assert!(why.contains("solid 'Cube': not imported: ") && why.contains("SMLib"), "{why}");
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
use super::*;
|
||||
|
||||
fn wedge() -> Body {
|
||||
/// A wedge 40 x 20, 15 high, its top front edge along x from 0 to 40.
|
||||
fn wedge_body() -> Body {
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
|
||||
let outer = Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(40., 0.), Vec2::new(0., 20.)]);
|
||||
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap();
|
||||
crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap()
|
||||
}
|
||||
|
||||
fn wedge() -> Body {
|
||||
let body = wedge_body();
|
||||
let at = Vec3::new(20., 0., 15.);
|
||||
// The room's taper, closing to zero at the wedge's tip, given as a spline fillet: R16 itself
|
||||
// is built over the wedge's cliffs (the blend-overflow decision), so a tapered fillet no
|
||||
@@ -12,6 +17,16 @@ fn wedge() -> Body {
|
||||
fillet_variable_spline(&body, at, t.start, &t.spline).unwrap()
|
||||
}
|
||||
|
||||
/// The wedge's variable round takes off the union of its balls (smlib#78, solidcore#2262):
|
||||
/// `removed_by_balls` along the 40 mm edge, about 754.28, on whichever kernel the test runs.
|
||||
#[test]
|
||||
fn the_wedge_round_keeps_its_volume() {
|
||||
let v = crate::measure(&wedge()).unwrap().volume;
|
||||
let law = taper(&wedge_body(), Vec3::new(20., 0., 15.), 16.).unwrap().spline;
|
||||
let want = 6000.0 - super::tests::removed_by_balls(40.0, &|u| law.eval(u), &|_, _| f64::INFINITY, &[]);
|
||||
assert!(((v - want) / want).abs() < 1e-4, "{v} vs {want}");
|
||||
}
|
||||
|
||||
/// The display mesh of a fillet tapering to zero at a wedge's tip converges to the B-rep: each
|
||||
/// mesh lies within its chord tolerance of the faces, so its volume is within the body's area
|
||||
/// times that tolerance of the exact one (SMLib's, integrated on the surfaces, to 1e-8). Only
|
||||
|
||||
@@ -1724,17 +1724,14 @@ fn tabbed_plate_area(da: f64, dt: f64) -> f64 {
|
||||
0.5 * (big * big * (PI + 2.0 * at) + tab) + (4.0 - dt) * (cy - yc)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
|
||||
/// The tabbed plate's outer outline, reconstructed from its seven public pick coordinates: an R25
|
||||
/// plate, x = ±4 tab sides, R2 corners centred at (±2, 280/9) and a 4 mm straight top.
|
||||
fn tabbed_plate_outline() -> Loop2 {
|
||||
use solidcraft_geom::Seg2;
|
||||
// Reconstruct the outer outline from its seven public pick coordinates:
|
||||
// R25 plate, x=+/-4 tab sides, R2 corners and a 4 mm straight top.
|
||||
// The hole's dimensions come from the public reproduction, not an original design file.
|
||||
let theta = (4.0_f64 / 25.0).acos();
|
||||
let y = (625.0_f64 - 16.0).sqrt();
|
||||
let corner_y = 280.0 / 9.0;
|
||||
let hole_y = 262.0 / 9.0;
|
||||
let outline = Loop2 {
|
||||
Loop2 {
|
||||
segs: vec![
|
||||
Seg2::Arc { center: Vec2::ZERO, radius: 25.0, start: PI - theta, sweep: PI + 2.0 * theta },
|
||||
Seg2::Line { a: Vec2::new(4.0, y), b: Vec2::new(4.0, corner_y) },
|
||||
@@ -1743,7 +1740,43 @@ fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
|
||||
Seg2::Arc { center: Vec2::new(-2.0, corner_y), radius: 2.0, start: PI / 2.0, sweep: PI / 2.0 },
|
||||
Seg2::Line { a: Vec2::new(-4.0, corner_y), b: Vec2::new(-4.0, y) },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// solidcore#2223: the tabbed plate's R25 rim and one R2 tab corner arc, which share no vertex,
|
||||
/// rounded together at r 0.5 on the plate 2 high (no hole). Each rounds alone; together they
|
||||
/// failed 1001 in cliff_overflow. They take off the sum: the rim alone's 7.958674274526857
|
||||
/// (solidcore#2176's pinned value) and the corner arc's Pappus volume with flat ends,
|
||||
/// 0.15913583972191622 (the spandrel (1 − π/4) r² about the corner's axis at 2 − its centroid
|
||||
/// over a quarter turn), 8.117810114.
|
||||
#[test]
|
||||
fn fillet_tabbed_plate_rim_and_one_corner_arc_together() {
|
||||
let body = extrude(&Plane::XY, &[Region2 { outer: tabbed_plate_outline(), holes: vec![] }], 0.0, 2.0).unwrap().pop().unwrap();
|
||||
let r = 0.5;
|
||||
let (spandrel, centroid) = ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
|
||||
let corner = spandrel * (PI / 2.0) * (2.0 - centroid);
|
||||
assert!((corner - 0.159_135_839_721_916_22).abs() < 1e-12, "{corner}");
|
||||
let want = 7.958_674_274_526_857 + corner;
|
||||
let picks = [
|
||||
Vec3::new(-24.91935125502191, -2.0064727829797544, 2.0),
|
||||
Vec3::new(24.919351255429795, -2.006472777913911, 2.0),
|
||||
Vec3::new(3.4142135598750105, 32.525324672801695, 2.0),
|
||||
];
|
||||
let blended = fillet(&body, &picks, r).unwrap_or_else(|e| panic!("rim and corner arc together: {e}"));
|
||||
assert!(blended.validity().is_empty(), "{:?}", blended.validity());
|
||||
assert_eq!(blended.face_count(), 12);
|
||||
let got = measure(&body).unwrap().volume - measure(&blended).unwrap().volume;
|
||||
assert!((got - want).abs() < 1e-7, "removed {got}, expected {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
|
||||
// The hole's dimensions come from the public reproduction, not an original design file.
|
||||
let theta = (4.0_f64 / 25.0).acos();
|
||||
let y = (625.0_f64 - 16.0).sqrt();
|
||||
let corner_y = 280.0 / 9.0;
|
||||
let hole_y = 262.0 / 9.0;
|
||||
let outline = tabbed_plate_outline();
|
||||
let body =
|
||||
extrude(&Plane::XY, &[Region2 { outer: outline, holes: vec![Loop2::circle(Vec2::new(0.0, hole_y), 1.5)] }], 0.0, 2.0).unwrap().pop().unwrap();
|
||||
let r = 0.5;
|
||||
|
||||
@@ -56,20 +56,12 @@ fn smlib_validity(body: &Body) -> Option<SmlibCheck> {
|
||||
|
||||
/// Whether this build links SMLib (the `smlib` feature).
|
||||
pub fn smlib_built() -> bool {
|
||||
cfg!(feature = "smlib")
|
||||
solidcraft_smlib::built()
|
||||
}
|
||||
|
||||
/// Where every SMLib call that fails from now on leaves its inputs for replay (the C API's
|
||||
/// `smlib_set_dump_dir`: the call's arguments and B-reps, which `smlib_brep_read` reads back);
|
||||
/// `None` stops it. A build without SMLib makes no calls to dump.
|
||||
/// Where every kernel call that fails from now on leaves its inputs for replay
|
||||
/// (`solidcraft_smlib::set_dump_dir`, on either kernel: the call and its B-reps, which `Brep::read`
|
||||
/// reads back); `None` stops it.
|
||||
pub fn smlib_dump_dir(dir: Option<&std::path::Path>) -> crate::Result<()> {
|
||||
#[cfg(feature = "smlib")]
|
||||
{
|
||||
solidcraft_smlib::set_dump_dir(dir).map_err(|e| crate::KernelError::Failed(format!("SMLib dump folder: {e}")))
|
||||
}
|
||||
#[cfg(not(feature = "smlib"))]
|
||||
{
|
||||
let _ = dir;
|
||||
Ok(())
|
||||
}
|
||||
solidcraft_smlib::set_dump_dir(dir).map_err(|e| crate::KernelError::Failed(format!("kernel dump folder: {e}")))
|
||||
}
|
||||
|
||||
@@ -20,14 +20,17 @@ type_complexity = "allow"
|
||||
neg_cmp_op_on_partial_ord = "allow"
|
||||
|
||||
[features]
|
||||
# Compile and link SMLib. Off, the crate builds without a C++ toolchain or the SMLib sources, and
|
||||
# every call returns `Error::NotBuilt`, so workspace-wide builds and checks need neither.
|
||||
# Compile and link the C++ SMLib (the apps' and the kernel's `smlib` feature turns it on; off by
|
||||
# default). Off, the crate builds without a C++ toolchain, the SMLib sources or a prebuilt library,
|
||||
# build.rs does nothing, solidcore answers every operation, and a call asked of the C++ kernel
|
||||
# returns `Error::NotBuilt`.
|
||||
native = []
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-profile = { workspace = true }
|
||||
solidcore = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# The clocks SMLib's C library asks for in a browser build (src/ffi.rs).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! SMLib's own validity check: `SmBrep::ValidatePointers`, then `AssertValid` over the whole
|
||||
//! topology graph. Tests run it on every SMLib result and every import.
|
||||
|
||||
use crate::{Brep, Error, Result, error, ffi};
|
||||
use crate::{Brep, Error, Result, core, error, ffi, route};
|
||||
|
||||
/// How many of SMLib's tests to run.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -53,13 +53,16 @@ impl Brep {
|
||||
/// Runs SMLib's validity check at `level`. A body that fails it is still `Ok`, with the
|
||||
/// failures in the report; `Err` means the check could not run.
|
||||
pub fn check(&self, level: CheckLevel) -> Result<CheckReport> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::check(self.core()?, level);
|
||||
}
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
let (len, failures) = match ffi::check::check(&self.0, level as i32, &mut buf) {
|
||||
let (len, failures) = match ffi::check::check(self.cpp()?, level as i32, &mut buf) {
|
||||
Ok(r) => r,
|
||||
// Too long for the first buffer: once more with the length it asked for.
|
||||
Err((ffi::ERR_BUFFER, needed)) => {
|
||||
buf = vec![0u8; needed.saturating_add(1)];
|
||||
ffi::check::check(&self.0, level as i32, &mut buf).map_err(|(s, _)| error(s))?
|
||||
ffi::check::check(self.cpp()?, level as i32, &mut buf).map_err(|(s, _)| error(s))?
|
||||
}
|
||||
Err((s, _)) => return Err(error(s)),
|
||||
};
|
||||
@@ -102,6 +105,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_copy_and_a_native_file_round_trip_stay_valid() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let b = Brep::make_box([0.0; 3], [2.0, 3.0, 4.0]).unwrap();
|
||||
assert!(b.try_clone().unwrap().check(CheckLevel::All).unwrap().is_valid());
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-check-{}", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,569 @@
|
||||
//! The solidcore backend: one kernel for the process (SMLib's context is process-global too, and
|
||||
//! some of its state, such as the fillet solver's latched step, lives for the whole process),
|
||||
//! every call under its lock and a panic guard.
|
||||
//!
|
||||
//! Upstream fixes ([`fixes`]) are solidcore's opt-in corrections of SMLib defects that the C++
|
||||
//! fork fixed in its own sources; each one turned on here says which fork commit it matches.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::kernel::{Kernel, KernelOptions, UpstreamFixes};
|
||||
use solidcore::mass::integrator::properties::{PropertyAttributes, brep_volume};
|
||||
use solidcore::primitive::api_primitives as primitives;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub(crate) mod blend;
|
||||
#[cfg(test)]
|
||||
mod blend_tests;
|
||||
pub(crate) mod boolean;
|
||||
#[cfg(test)]
|
||||
mod boolean_tests;
|
||||
mod convert;
|
||||
#[cfg(test)]
|
||||
mod convert_tests;
|
||||
pub(crate) mod dump;
|
||||
pub(crate) mod file;
|
||||
pub(crate) mod history;
|
||||
mod measure;
|
||||
#[cfg(test)]
|
||||
mod measure_tests;
|
||||
pub(crate) mod merge;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
pub(crate) mod offset;
|
||||
#[cfg(test)]
|
||||
mod offset_tests;
|
||||
pub(crate) mod split;
|
||||
#[cfg(test)]
|
||||
mod split_tests;
|
||||
pub(crate) mod ssi;
|
||||
pub(crate) mod sweep;
|
||||
#[cfg(test)]
|
||||
mod sweep_tests;
|
||||
pub(crate) mod transform;
|
||||
#[cfg(test)]
|
||||
mod transform_tests;
|
||||
pub(crate) mod trim;
|
||||
#[cfg(test)]
|
||||
mod trim_tests;
|
||||
|
||||
pub(crate) use convert::{from_arrays, to_arrays};
|
||||
pub(crate) use measure::{area, bounds, check, classify_point, edge_face_distance, is_manifold_solid, mass_properties, solid_count};
|
||||
|
||||
/// A solidcore body. Its own lock: solidcore's operations take `&mut Brep`, even to measure.
|
||||
pub(crate) struct Body(pub(crate) Mutex<solidcore::topology::Brep>);
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn new(b: solidcore::topology::Brep) -> Body {
|
||||
Body(Mutex::new(b))
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixes this backend runs with: every one of solidcore's opt-in fixes of upstream SMLib
|
||||
/// defects (`UpstreamFixes`, each naming its record and issue), whether or not the C++ fork has it,
|
||||
/// unless it appears broken. A test checks none is left off but those in `HELD_BACK`, so moving the
|
||||
/// solidcore pin to a rev with a new flag fails until it is added here.
|
||||
pub(crate) fn fixes() -> UpstreamFixes {
|
||||
let mut f = UpstreamFixes::default();
|
||||
f.fillet_retry_radius_as_first = true;
|
||||
f.fillet_rail_rim_on_face = true;
|
||||
f.fillet_refuse_rails_leaving_faces = true;
|
||||
f.fillet_refuse_face_consuming_blends = true;
|
||||
f.fillet_refuse_non_manifold_result = true;
|
||||
f.fillet_vertex_error_diagnostics = true;
|
||||
f.fillet_corner_ball_patch = true;
|
||||
f.fillet_chamfer_cone_on_arc = true;
|
||||
f.fillet_torus_center_on_axis = true;
|
||||
f.offset_excess_faces_after_merge = true;
|
||||
f.offset_clip_right_side_out = true;
|
||||
f.revolution_offset_axis_both_sides = true;
|
||||
f.offset_extended_edges_mated = true;
|
||||
f.remove_slit_loops_where_solids_touch = true;
|
||||
f.keep_operand_uv_trims = true;
|
||||
f.proto_vertex_miss_is_false = true;
|
||||
f.check_loop_vertex_tolerance = true;
|
||||
f.check_loop_projected_seam_ends = true;
|
||||
f.trim_boundary_seam_wrap = true;
|
||||
f.bspline_check_min_radius = true;
|
||||
f.oscillating_skips_failed_guesses = true;
|
||||
f.refine_cc_range_ends = true;
|
||||
f.flat_corners_test_bits = true;
|
||||
f.cylinder_rebuild_mean_radius = true;
|
||||
f.planar_path_uncapped_open_profiles = true;
|
||||
f.revolve_analytic_profile_faces = true;
|
||||
f.revolve_arc_centre_near_axis_is_sphere = true;
|
||||
f.taper_pivot_nearest_corner = true;
|
||||
f.taper_tangent_corner_planes = true;
|
||||
f.revolve_nested_shells_as_cavities = true;
|
||||
f.revolve_holed_profile_void = true;
|
||||
f.curve_sweep_caps_at_sweep_ends = true;
|
||||
f.join_touching_loops = true;
|
||||
f.uv_trim_reversal_on_surface = true;
|
||||
f.offset_resolve_crossing_loops = true;
|
||||
f.offset_walls_touch_refused = true;
|
||||
f.offset_analytic_mate_edges = true;
|
||||
f.precise_properties_split_at_surface_knots = true;
|
||||
f.nurb_cylinder_inside_out_frame = true;
|
||||
f.shell_inner_shell_of_statuses = true;
|
||||
f.local_operation_offset_status = true;
|
||||
f.local_operation_refuses_loose_tangent_edges = true;
|
||||
f.torus_major_radius_from_origin = true;
|
||||
f.fillet_corner_sphere_from_four_arcs = true;
|
||||
f.fillet_nx1_opposite_continuation_side_edge = true;
|
||||
f.fillet_trace_rejects_short_step = true;
|
||||
f.torus_iso_circle_axis_from_midpoint = true;
|
||||
f.torus_keeps_inside_out = true;
|
||||
f.internal_pole_row_through_knot = true;
|
||||
f.extrusion_origin_scaled_zero = true;
|
||||
f.transform_scales_each_tolerance = true;
|
||||
f.transform_model_size_from_body_box = true;
|
||||
f.offset_collapsed_rounds_mated = true;
|
||||
f.offset_missing_surface_fails = true;
|
||||
f.fillet_nx1_end_rib_global_merge = true;
|
||||
f.uv_trim_corner_end_points_only = true;
|
||||
f.bspline_trim_preserves_uv_spans = true;
|
||||
f.cone_parallel_line_midpoint = true;
|
||||
f.cone_parallel_line_domains = true;
|
||||
f.proto_edge_self_loop_on_vertex_degenerate = true;
|
||||
f.slice_coincident_faces_sheet_side = true;
|
||||
f.boolean_outer_shell_first = true;
|
||||
f.edge_reversal_keeps_failed_edge = true;
|
||||
f.topology_failed_evaluations_propagate = true;
|
||||
f.heal_stage3_failures_propagate = true;
|
||||
f.sweep_frame_on_planar_path_normal = true;
|
||||
f.cover_refuses_folded_continuation = true;
|
||||
f.boolean_vertex_by_best_crossing = true;
|
||||
f.boolean_cleanup_removes_slits = true;
|
||||
f.classify_edge_interval = true;
|
||||
f.ellipse_trim_seam_lines_span_cone_height = true;
|
||||
f.degenerate_proto_edge_in_one_vertex = true;
|
||||
f.new_edge_vertex_tolerance_covers_gap = true;
|
||||
f.torus_signed_gen_curve_interval = true;
|
||||
f.sheet_sweep_joins_profile_wires = true;
|
||||
f.point_solve_keeps_triangle_guess = true;
|
||||
f.fillet_setback_from_start_vertex = true;
|
||||
f.imprint_trims_edge_to_proto_vertex = true;
|
||||
f.imprint_extends_edge_to_proto_vertex = true;
|
||||
f.fillet_point_step_per_point = true;
|
||||
f.analytic_copy_keeps_parameterization = true;
|
||||
f.fillet_point_bigger_step_retry = true;
|
||||
f.fillet_through_extended_rail_end = true;
|
||||
f.precise_properties_body_length_scale = true;
|
||||
f.offset_extension_from_base = true;
|
||||
f.corner_blend_stays_inside_neighbours = true;
|
||||
f.fillet_cleanup_keeps_target_faces = true;
|
||||
f.fillet_nx1_tangent_side_edge = true;
|
||||
f.fillet_3x2_mixed_concave_side_edge = true;
|
||||
f.offset_closed_curve_trim_arc = true;
|
||||
f.offset_stitch_at_body_tolerance = true;
|
||||
f.curve_contacts_at_curve_ends = true;
|
||||
f.shell_empty_cavity_fails = true;
|
||||
f.fillet_refuse_ball_not_fitting = true;
|
||||
f.boolean_lists_virtual_surface_copies = true;
|
||||
f.sphere_small_circle_not_touching = true;
|
||||
f.ssi_tangent_contact_points = true;
|
||||
f.ssi_trace_tangent_curves_once = true;
|
||||
f.ssi_singular_start_inside_direction = true;
|
||||
f.found_torus_sphere_keep_tested_nurb = true;
|
||||
f.boolean_skips_faces_apart = true;
|
||||
f.uv_trim_checks_on_surface = true;
|
||||
f.loop_orientation_floor_from_loop_box = true;
|
||||
f.offset_extends_cones_as_themselves = true;
|
||||
f.local_operation_box_holds_new_surfaces = true;
|
||||
f.uv_trim_joins_drop_pieces = true;
|
||||
f.shell_edges_on_analytics_exact = true;
|
||||
f.ssi_no_reverse_of_empty_trace = true;
|
||||
f.ssi_pole_point_keeps_its_tangent = true;
|
||||
f.iso_curve_sliver_domain_is_degenerate = true;
|
||||
f.corner_blend_coons_patch_tangent_to_sides = true;
|
||||
f.fillet_patch_keeps_fillet_edges = true;
|
||||
f.fillet_corner_ball_center_from_axes = true;
|
||||
f.seam_point_solutions_are_one = true;
|
||||
f.boolean2d_plane_covers_far_faces = true;
|
||||
f.brep_area_between_solid_and_void = true;
|
||||
f.boolean_splits_definition_across_split = true;
|
||||
f.curve_tight_box_interval_knots = true;
|
||||
f.fillet_refuse_overlapping_bands = true;
|
||||
f.ssi_tangent_curve_tracer = true;
|
||||
f.torus_check_reads_v_interval = true;
|
||||
f.torus_check_spindle_apex = true;
|
||||
f.local_operation_step_walls = true;
|
||||
f.edge_check_orders_tangent_faces = true;
|
||||
f.revolve_arc_touching_axis_is_horn_torus = true;
|
||||
f.thicken_planar_side_walls = true;
|
||||
f.fillet_torus_span_from_start_section = true;
|
||||
f.fillet_knife_edge_rounds = true;
|
||||
f.taper_wall_closes_at_apex = true;
|
||||
f.revolution_round_offset_collapses = true;
|
||||
f.fillet_chamfer_cone_stored_as_cone = true;
|
||||
f.offset_point_vertex_caps = true;
|
||||
f.approximate_surface_bezier_starter = true;
|
||||
f.proto_edge_splits_at_captured_vertices = true;
|
||||
f.stitch_solid_nests_cavities = true;
|
||||
f.local_operation_closes_walls_to_nothing = true;
|
||||
f.linear_sweep_arc_to_cylinder_zero = true;
|
||||
f.imprint_reuses_coincident_face_edge = true;
|
||||
f.boolean_new_face_closure_both_sides = true;
|
||||
f.linear_sweep_arc_is_cylinder = true;
|
||||
f.offset_closes_loop_gaps = true;
|
||||
f.cylinder_plane_tangent_within_tolerance = true;
|
||||
f.boolean_wire_edge_keeps_faceuse_shell = true;
|
||||
f.fillet_nx1_tangent_corner_edge = true;
|
||||
f.fillet_torus_rail_on_axis = true;
|
||||
f.loops_go_on_round_the_vertex = true;
|
||||
f.revolve_splits_curves_on_axis = true;
|
||||
f.cover_wraps_across_closed_seam = true;
|
||||
f.imprint_extension_keeps_analytic_curve = true;
|
||||
f.fillet_trace_from_tangent_point_toward_next = true;
|
||||
f.fillet_tangent_point_one_sided_step = true;
|
||||
f.fillet_rails_meet_at_one_end_point = true;
|
||||
f.fillet_trace_reaches_rails = true;
|
||||
f.delete_vertex_joins_seam_iso_curve = true;
|
||||
f.box_face_failures_propagate = true;
|
||||
f.ellipse_adjust_step_interval_keeps_turns = true;
|
||||
f.fillet_cleanup_removes_input_slits = true;
|
||||
f.fillet_rail_end_on_continued_surface = true;
|
||||
f.glue_vertices_joins_every_region = true;
|
||||
f.fillet_nx1_tangent_side_edge_same_convexity = true;
|
||||
f.fillet_removal_takes_ring_fillets = true;
|
||||
// The fork's commits that reached `SMLIB_REV` at eaa998a (tangency-labs/smlib#68, #69, #70).
|
||||
f.fillet_trim_inside_by_best_dot = true;
|
||||
f.fillet_half_degree_crease = true;
|
||||
f.fillet_split_edge_keeps_near_half = true;
|
||||
f.curve_loop_normal_from_area = true;
|
||||
// The fork's commits in `SMLIB_REV` 2fc375f, ported to solidcore since.
|
||||
f.fillet_cliff_overflow = true;
|
||||
f.fillet_3x2_tangent_side_edge_roles = true;
|
||||
f.offset_extension_as_itself = true;
|
||||
f.fillet_mitre_concave_face_corners = true;
|
||||
f.fillet_mitre_gap_patch = true;
|
||||
f.fillet_run_on_past_far_end_only = true;
|
||||
f.good_drop_result_distances_in_order = true;
|
||||
f.face_tight_box_searches_trim = true;
|
||||
f.ssi_low_angle_start_on_probation = true;
|
||||
f.fillet_variable_span_follows_fillet = true;
|
||||
f.simple_coincidence_crossing_is_point = true;
|
||||
f.boolean_temporary_transform_statuses = true;
|
||||
f.primitive_path_curve_statuses = true;
|
||||
f.planar_path_sweep_statuses = true;
|
||||
f.planar_path_profile_placement_statuses = true;
|
||||
f.fillet_trim_status = true;
|
||||
f.perspective_sphere_offsets = true;
|
||||
f.plane_recognition_origin_after_axes = true;
|
||||
f.fillet_corner_face_curve_halves = true;
|
||||
f.brep_edit_statuses_pass_on = true;
|
||||
f.fillet_run_on_bspline_trims = true;
|
||||
f.offset_tangent_gap_statuses = true;
|
||||
f.ssi_alignment_statuses = true;
|
||||
f.fillet_corner_statuses = true;
|
||||
f.fillet_edge_curve_domain_statuses = true;
|
||||
f.query_statuses_pass_on = true;
|
||||
f.sweep_statuses_pass_on = true;
|
||||
f.ssi_proto_topology_statuses_pass_on = true;
|
||||
f.analytic_inversion_answers_apart = true;
|
||||
f.fillet_statuses_pass_on = true;
|
||||
f.fillet_corner_per_vertex_fan = true;
|
||||
f.fillet_rail_trim_at_near_knot = true;
|
||||
f.offset_splits_corner_vertices = true;
|
||||
f.offset_keep_cavity_regions = true;
|
||||
f.offset_four_face_vertex_split = true;
|
||||
f.gap_samples_non_bspline_v_stride = true;
|
||||
f.gap_array_append_updates_longest = true;
|
||||
f.imprint_skips_collapsed_definition = true;
|
||||
f.precise_properties_trim_base_v = true;
|
||||
f.fillet_blend_curve_revolved_on_arc = true;
|
||||
f.temporary_radius_setup_errors = true;
|
||||
f.ellipse_step_interval_checks = true;
|
||||
f.corner_patch_face_side_twist = true;
|
||||
f.boolean_union_refuses_solid_leak = true;
|
||||
f.curve_loop_start_after_previous_end = true;
|
||||
f.loop_use_reports_open_gaps = true;
|
||||
f.face_points_span_domain = true;
|
||||
f.drop_curve_v_domain_degeneracy = true;
|
||||
f.mirror_curve_keeps_euclidean_control_points = true;
|
||||
f.fillet_corner_variable_radius_rail_on_opposite_fillet = true;
|
||||
f.sphere_from_arcs_common_centre = true;
|
||||
f.dupin_bottom_hyperbola_axis = true;
|
||||
f.extent3d_contains_both_segment_endpoints = true;
|
||||
f.translation_sweep_regularize = true;
|
||||
f.gap_samples_use_trimmed_spans = true;
|
||||
f.twist_simple_duw_sign = true;
|
||||
f.offset_swap_uv_extended_distance = true;
|
||||
f.circumscribe_axis_map_by_basis = true;
|
||||
f.planar_path_profile_in_path_frame = true;
|
||||
f.validation_does_not_make_uv_trims = true;
|
||||
f.iso_curve_uv_line_both_params = true;
|
||||
f.extrusion_step_uses_clamped_uv = true;
|
||||
f.analytic_corner_failure_is_false = true;
|
||||
f.defeature_refuses_open_base = true;
|
||||
f.ssi_start_loop_trace_budget = true;
|
||||
f.fillet_refuse_crossing_rails = true;
|
||||
f.proto_edge_split_upper_keeps_the_old_max = true;
|
||||
f.point_on_start_point_squared_tolerance = true;
|
||||
f.bspline_trim_keeps_rational_end_spans = true;
|
||||
f.fillet_exact_round_classes = true;
|
||||
f.curve_check_zero_speed_tangent_end = true;
|
||||
f.cpoly_remove_poly_face_check_member = true;
|
||||
f.check_seam_crossing_unclosed = true;
|
||||
f.ascii_doubles_round_trip = true;
|
||||
f.transform_aligned_bbox_image = true;
|
||||
f.bspline_simple_offset_matches_parameterization = true;
|
||||
f.offset_edges_keep_source_tolerance = true;
|
||||
f.best_fit_line_largest_scatter_eigenvector = true;
|
||||
f.circle_silhouette_perspective_center = true;
|
||||
f.ellipse_convert_to_2d_uses_both_axes = true;
|
||||
f.circle_offset_antiparallel_normal = true;
|
||||
f.masked_array_uses_bit_shift = true;
|
||||
f.global_solver_identical_prefers_larger_when_maximizing = true;
|
||||
f.surface_continuity_checks_last_v_knot = true;
|
||||
f.surface_point_solver_propagates_bbox_failure = true;
|
||||
f.volume_lift_third_derivative_terms = true;
|
||||
f.volume_discontinuity_checks_vw_unaligned = true;
|
||||
f.periodic_extent_add_empty_interval = true;
|
||||
f.stop_tracking_checks_own_callback = true;
|
||||
f.plane_reparameterize_moves_origin = true;
|
||||
f.crv_in_volume_mults_by_break_index = true;
|
||||
f.curve_bounded_surface_read_uv_type_and_implicit_outer = true;
|
||||
f.polar_box_region_to_arc_transposes_domain = true;
|
||||
f.point_set_centroid_all_blocks = true;
|
||||
f.local_solve1d_unbounded_newton_step = true;
|
||||
f.composite_curve_bbox_segment_intervals = true;
|
||||
f.knot_vector_valid_requires_span = true;
|
||||
f.projected_curve_transform_when_owned = true;
|
||||
f.curve_property_gs_branch_bounds = true;
|
||||
f.volume_representational_end_continuities = true;
|
||||
f.integrator_propagates_failure = true;
|
||||
f.precise_properties_span_end_from_below = true;
|
||||
f.offset_open_edge_second_trim_point = true;
|
||||
f.close_loop_relates_only_coincident_edges = true;
|
||||
f.ssi_split_curves_at_singular_points = true;
|
||||
f.surface_parallel_to_vector_checks = true;
|
||||
f.fillet_round_ends_at_tangent_continuation = true;
|
||||
f.geometric_g2_continuity = true;
|
||||
f.hash_table_grow_preserves_removed_records = true;
|
||||
f.bend_volume_bbox_contains_wide_angle_and_inf_u = true;
|
||||
f.bend_volume_trim_param_box_across_period = true;
|
||||
f.point_set_tight_pbox_projects_points = true;
|
||||
f.degen_param_tiebreak_uses_actual_param = true;
|
||||
f.conic_evaluate_uses_computed_second_deriv = true;
|
||||
f.cylinder_plane_section_trims_exact = true;
|
||||
f.local_surface_intersect_guards_max_boundary_retry = true;
|
||||
f.curve_max_distance_single_sample_midpoint = true;
|
||||
f.curve_sweep_closed_path_joins_ends = true;
|
||||
f.validate_tolerances_fixes_reported_gaps = true;
|
||||
f.edgeuse_convexity_propagates_step_off_failure = true;
|
||||
f.mark_lock_pair_advances_and_locks_second_context = true;
|
||||
f.is_iso_curve_at_propagates_canonical_failure = true;
|
||||
f.database_io_byte_swaps_read_boolean_and_u16 = true;
|
||||
f.is_point_on_curve_propagates_eval_failure = true;
|
||||
f.is_topological_vertex_rejects_cusps_and_tangent_errors = true;
|
||||
f.cone_create_canonical_preserves_inside_out_and_checks_height = true;
|
||||
f.point_class_gap_3d_propagates_no_drop_error = true;
|
||||
f.fillet_surface_plane_curve_on_plane_equation = true;
|
||||
f.fillet_extended_rail_end_at_side_edge_crossing = true;
|
||||
f.fillet_gn_section_generator = true;
|
||||
f.face_trim_curves_kept_where_exact = true;
|
||||
f.swap_uv_keeps_analytic_definition = true;
|
||||
f.face_shrink_bound_on_knot = true;
|
||||
f.offset_updates_result_tolerances = true;
|
||||
f.cone_iso_circle_at_axial_height = true;
|
||||
f.boolean_coincident_strip_drops_crossing = true;
|
||||
f.fillet_overflow_trim_at_creases = true;
|
||||
f.fillet_section_end_trim_at_creases = true;
|
||||
f
|
||||
}
|
||||
|
||||
/// The fixes held off, each with the case and the numbers that show it broken: a volume or topology
|
||||
/// wrong against the analytic value, a check failure, a crash, sound geometry refused that builds
|
||||
/// without the flag, or a large slowdown. Differing from the C++ kernel is no such evidence: a fix
|
||||
/// the fork doesn't have stays on, and where solidcore builds what SMLib refuses, a result right
|
||||
/// against its closed form is a win.
|
||||
#[cfg(test)]
|
||||
const HELD_BACK: &[&str] = &[];
|
||||
|
||||
fn fresh() -> Kernel {
|
||||
let mut k = Kernel::new(&KernelOptions::default());
|
||||
k.fixes = fixes();
|
||||
k
|
||||
}
|
||||
|
||||
pub(crate) static KERNEL: Mutex<Option<Kernel>> = Mutex::new(None);
|
||||
|
||||
/// Runs `f` on the process's kernel. A panic in solidcore is caught: the kernel, which it may have
|
||||
/// left half-updated, is replaced by a fresh one and the call fails with [`Error::Internal`].
|
||||
pub(crate) fn run<T: dump::Answer>(call: &'static str, f: impl FnOnce(&mut Kernel) -> std::result::Result<T, solidcore::SolidError>) -> Result<T> {
|
||||
let mut guard = KERNEL.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
let k = guard.get_or_insert_with(fresh);
|
||||
// A profiler span per call, as the C API's calls are: where the kernel's time goes.
|
||||
let _span = solidcraft_profile::span("solidcore", call);
|
||||
#[cfg(test)]
|
||||
crate::route::reached_core();
|
||||
dump::begin();
|
||||
dump::set_current(Some(call));
|
||||
let r = match catch_unwind(AssertUnwindSafe(|| f(k))) {
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
Ok(Err(e)) => Err(Error::Solidcore(format!("{call}: {e}"))),
|
||||
Err(p) => {
|
||||
*guard = Some(fresh());
|
||||
let why = p.downcast_ref::<&str>().map(|s| (*s).to_owned()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::warn!(target: "smlib", "solidcore panicked in {call}: {why}");
|
||||
Err(Error::Internal(format!("{call}: {why}")))
|
||||
}
|
||||
};
|
||||
dump::set_current(None);
|
||||
match &r {
|
||||
Ok(v) => {
|
||||
if let Some(why) = v.refusal() {
|
||||
dump::failed(call, &why);
|
||||
}
|
||||
}
|
||||
Err(e) => dump::failed(call, &e.to_string()),
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A call the capi checks for `SM_SUCCESS`, treating any other status as no answer: an error
|
||||
/// that stands for an SMLib status exactly ([`SolidError::sm_status`](solidcore::SolidError::sm_status),
|
||||
/// solidcore#1452) is `None`. Every other solidcore error
|
||||
/// (unported code, invalid input to an adapter, a missing record...) is not a status the C++
|
||||
/// path returns, so it fails the call instead of hiding as a skipped entity.
|
||||
pub(crate) fn answer<T>(r: std::result::Result<T, solidcore::SolidError>) -> std::result::Result<Option<T>, solidcore::SolidError> {
|
||||
match r {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(e) if e.sm_status().is_some() => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on the kernel and `body`, locking the kernel first.
|
||||
pub(crate) fn with_body<T: dump::Answer>(
|
||||
call: &'static str,
|
||||
body: &Body,
|
||||
f: impl FnOnce(&mut Kernel, &mut solidcore::topology::Brep) -> std::result::Result<T, solidcore::SolidError>,
|
||||
) -> Result<T> {
|
||||
// A body a panic interrupted may be half-edited: it is never used again.
|
||||
if body.0.is_poisoned() {
|
||||
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
|
||||
}
|
||||
run(call, |k| {
|
||||
let mut b = body.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
dump::input(k, "brep", &b);
|
||||
f(k, &mut b)
|
||||
})
|
||||
}
|
||||
|
||||
/// An axis-aligned box from its lower corner (`SmApiCreateBox`, as `smlib_make_box` checks it:
|
||||
/// every edge length positive).
|
||||
pub(crate) fn make_box(origin: [f64; 3], size: [f64; 3]) -> Result<Body> {
|
||||
if !origin.iter().chain(&size).all(|x| x.is_finite()) || !size.iter().all(|&s| s > 0.0) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let [x, y, z] = size;
|
||||
run("create_box", |k| primitives::create_box(k, Vec3::new(origin[0], origin[1], origin[2]), x, y, z)).map(Body::new)
|
||||
}
|
||||
|
||||
fn point(p: [f64; 3]) -> Result<Vec3> {
|
||||
if !p.iter().all(|x| x.is_finite()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(Vec3::new(p[0], p[1], p[2]))
|
||||
}
|
||||
|
||||
/// A cylinder about +Z from its base centre (`SmApiCreateCylinder`, as `smlib_make_cylinder`
|
||||
/// checks it: radius and height positive).
|
||||
pub(crate) fn make_cylinder(origin: [f64; 3], radius: f64, height: f64) -> Result<Body> {
|
||||
let o = point(origin)?;
|
||||
if !(radius > 0.0 && height > 0.0 && radius.is_finite() && height.is_finite()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
run("create_cylinder", |k| primitives::create_cylinder(k, o, radius, height)).map(Body::new)
|
||||
}
|
||||
|
||||
/// A sphere (`SmApiCreateSphere`; radius positive).
|
||||
pub(crate) fn make_sphere(center: [f64; 3], radius: f64) -> Result<Body> {
|
||||
let o = point(center)?;
|
||||
if !(radius > 0.0 && radius.is_finite()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
run("create_sphere", |k| primitives::create_sphere(k, o, radius)).map(Body::new)
|
||||
}
|
||||
|
||||
/// A cone or frustum about +Z (`SmApiCreateCone`; `smlib_make_cone`'s checks: base radius and
|
||||
/// height positive, top radius at least 0 and not the base radius).
|
||||
pub(crate) fn make_cone(origin: [f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result<Body> {
|
||||
let o = point(origin)?;
|
||||
let ok = base_radius > 0.0 && top_radius >= 0.0 && height > 0.0 && top_radius != base_radius;
|
||||
if !(ok && base_radius.is_finite() && top_radius.is_finite() && height.is_finite()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
run("create_cone", |k| primitives::create_cone(k, o, base_radius, top_radius, height)).map(Body::new)
|
||||
}
|
||||
|
||||
/// A ring torus about +Z (`SmApiCreateTorus`; major radius > minor radius > 0).
|
||||
pub(crate) fn make_torus(center: [f64; 3], major_radius: f64, minor_radius: f64) -> Result<Body> {
|
||||
let o = point(center)?;
|
||||
if !(minor_radius > 0.0 && minor_radius < major_radius && major_radius.is_finite()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
run("create_torus", |k| primitives::create_torus(k, o, major_radius, minor_radius)).map(Body::new)
|
||||
}
|
||||
|
||||
/// The enclosed volume (`SmApiBrepComputeVolume`).
|
||||
pub(crate) fn volume(body: &Body, relative_accuracy: f64) -> Result<f64> {
|
||||
if !relative_accuracy.is_finite() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
with_body("brep_volume", body, |k, b| {
|
||||
let store = AttributeStore::default();
|
||||
let attrs = PropertyAttributes { store: &store, key_of: &|_| ObjectKey(0) };
|
||||
brep_volume(k, b, relative_accuracy, &attrs)
|
||||
})
|
||||
}
|
||||
|
||||
/// Face, edge and vertex counts.
|
||||
pub(crate) fn counts(body: &Body) -> Result<[usize; 3]> {
|
||||
with_body("counts", body, |_, b| Ok([b.face_list().len(), b.edge_list().len(), b.vertex_list().len()]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_upstream_fix_is_on_unless_shown_broken() {
|
||||
let f = fixes();
|
||||
let flags: Vec<(&str, bool)> = f.flags().collect();
|
||||
let off: Vec<&str> = flags.iter().filter(|(name, on)| !on && !HELD_BACK.contains(name)).map(|(name, _)| *name).collect();
|
||||
assert!(off.is_empty(), "UpstreamFixes flags are off: {off:?}");
|
||||
for name in HELD_BACK {
|
||||
assert!(flags.contains(&(*name, false)), "{name} is on or gone: drop it from HELD_BACK");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_measures_exactly() {
|
||||
let b = make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]).unwrap();
|
||||
assert_eq!(counts(&b).unwrap(), [6, 12, 8]);
|
||||
let v = volume(&b, 1e-8).unwrap();
|
||||
assert!((v - 6000.0).abs() < 6000.0 * 1e-9, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_refused() {
|
||||
assert!(make_box([f64::NAN, 0.0, 0.0], [1.0; 3]).is_err());
|
||||
assert!(make_box([0.0; 3], [1.0, -1.0, 1.0]).is_err());
|
||||
let b = make_box([0.0; 3], [1.0; 3]).unwrap();
|
||||
assert!(volume(&b, f64::INFINITY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_is_an_error_and_the_kernel_is_fresh_after() {
|
||||
let r: Result<()> = run("test", |_| panic!("boom"));
|
||||
assert!(matches!(r, Err(Error::Internal(ref m)) if m.contains("boom")), "{r:?}");
|
||||
assert!(make_box([0.0; 3], [1.0; 3]).is_ok());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
//! Booleans on solidcore: the C API's own logic round `SM_API`, ported from the fork's
|
||||
//! `capi/src/smlib_ops.cpp` (`smlib_boolean_with`, `smlib_boolean2d`,
|
||||
//! `smlib_boolean_union_all`, `finish`, `closed_nonmanifold`) and
|
||||
//! `capi/src/smlib_imprint.cpp` (`smlib_imprint`).
|
||||
//!
|
||||
//! As there, every operation works on copies of its operands (`SmApiBrepCopy`): `SM_API` edits
|
||||
//! the first operand in place and consumes the second, and a SolidCraft body never changes.
|
||||
|
||||
use std::sync::PoisonError;
|
||||
|
||||
use solidcore::Result as SolidResult;
|
||||
use solidcore::SolidError;
|
||||
use solidcore::boolean::api_brep::{self as sm, Boolean2dOutcome};
|
||||
use solidcore::boolean::merge::MergeOptions;
|
||||
use solidcore::geom::a_object::BooleanOperation;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::primitive::primitive_creation::p1::Boolean2d;
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
use solidcore::topology::brep::{p1, p2};
|
||||
use solidcore::topology::{Brep, edge};
|
||||
|
||||
use super::{Body, run};
|
||||
use crate::{BooleanOp, Error, Result};
|
||||
|
||||
/// What an operation's `SM_API` call gave, before it is a SolidCraft result: the capi's
|
||||
/// statuses besides success.
|
||||
enum Done {
|
||||
Body(Box<Brep>),
|
||||
/// `SMLIB_ERR_EMPTY`.
|
||||
Empty,
|
||||
/// `SMLIB_ERR_NOT_SOLID`.
|
||||
NotSolid,
|
||||
/// `SMLIB_ERR_NO_CUT` (`smlib_imprint`).
|
||||
NoCut,
|
||||
}
|
||||
|
||||
impl super::dump::Answer for Done {
|
||||
fn refusal(&self) -> Option<String> {
|
||||
matches!(self, Done::NotSolid).then(|| Error::NotSolid.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetNumFaces`.
|
||||
fn num_faces(b: &Brep) -> usize {
|
||||
b.face_list().len()
|
||||
}
|
||||
|
||||
/// `closed_nonmanifold` (smlib_ops.cpp): a closed body whose solids touch only along edges or at
|
||||
/// vertices: at least two regions, no shell vertices, no wire edge, and four edgeuses (two faces,
|
||||
/// both sides) per face round every edge. As there, an edge whose query fails (`e == nullptr`, a
|
||||
/// failed `GetEdgeuses`) makes it false: [`edge_closes`].
|
||||
fn closed_nonmanifold(r: &Brep) -> SolidResult<bool> {
|
||||
if p1::regions(r, None)?.len() < 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
if !p2::shell_vertices(r, None)?.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
for e in p1::edges(r, None)? {
|
||||
if !edge_closes(edge::is_wire(r, e), edge::edgeuses(r, e, None, None).map(|u| u.len())) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The capi's test of one edge in `closed_nonmanifold`, given its `IsWire` and the count of its
|
||||
/// `GetEdgeuses`: not a wire, and four edgeuses per face round it. Either query failing is
|
||||
/// false, not an error: the capi returns false where `GetEdgeuses` fails.
|
||||
pub(super) fn edge_closes(wire: SolidResult<bool>, uses: SolidResult<usize>) -> bool {
|
||||
matches!(wire, Ok(false)) && matches!(uses, Ok(n) if n >= 4 && n % 4 == 0)
|
||||
}
|
||||
|
||||
/// `finish` (smlib_ops.cpp): the result must have faces, and a solid input must give a closed
|
||||
/// manifold solid or (`nonmanifold`) a closed one whose solids touch along an edge or at a vertex.
|
||||
fn finish(r: Brep, solid_in: bool, nonmanifold: bool) -> SolidResult<Done> {
|
||||
if num_faces(&r) == 0 {
|
||||
return Ok(Done::Empty);
|
||||
}
|
||||
if solid_in && !p2::is_manifold_solid(&r)? && !(nonmanifold && closed_nonmanifold(&r)?) {
|
||||
return Ok(Done::NotSolid);
|
||||
}
|
||||
Ok(Done::Body(Box::new(r)))
|
||||
}
|
||||
|
||||
/// What a failing call's dump calls its first operands.
|
||||
const OPERANDS: [&str; 4] = ["a", "b", "c", "d"];
|
||||
|
||||
/// Copies of `bodies`, in order (a body given twice is copied twice, as the capi copies each
|
||||
/// operand), all made under the kernel's lock.
|
||||
fn copies(k: &mut Kernel, bodies: &[&Body]) -> SolidResult<Vec<Brep>> {
|
||||
// Under the kernel's lock (`run` holds it), as every body lock is: two never deadlock.
|
||||
let mut out = Vec::with_capacity(bodies.len());
|
||||
for (n, b) in bodies.iter().enumerate() {
|
||||
let g = b.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
super::dump::input(k, OPERANDS.get(n).copied().unwrap_or("operand"), &g);
|
||||
let c = brep_copy(k, &g)?;
|
||||
drop(g);
|
||||
out.push(c);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn result(d: Done, empty: Result<Option<Body>>) -> Result<Option<Body>> {
|
||||
match d {
|
||||
Done::Body(b) => Ok(Some(Body::new(*b))),
|
||||
Done::Empty => empty,
|
||||
Done::NotSolid => Err(Error::NotSolid),
|
||||
Done::NoCut => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation(op: BooleanOp) -> BooleanOperation {
|
||||
match op {
|
||||
BooleanOp::Union => BooleanOperation::Union,
|
||||
BooleanOp::Subtract => BooleanOperation::Difference,
|
||||
BooleanOp::Intersect => BooleanOperation::Intersection,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every body is a manifold solid (`IsManifoldSolid` of each copy).
|
||||
fn all_solid(bodies: &[Brep]) -> SolidResult<bool> {
|
||||
for b in bodies {
|
||||
if !p2::is_manifold_solid(b)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Fails when a panic left one of `bodies` half-edited: it is never used again.
|
||||
fn usable(call: &'static str, bodies: &[&Body]) -> Result<()> {
|
||||
if bodies.iter().any(|b| b.0.is_poisoned()) {
|
||||
return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `smlib_boolean` (`smlib_boolean_with` with `SMLIB_RESULT_NONMANIFOLD`): `SmApiBoolean` on
|
||||
/// copies of `a` and `b`; `None` when the result is empty (`SMLIB_ERR_EMPTY`), and
|
||||
/// [`Error::NotSolid`] when solid operands give a body that is not closed.
|
||||
pub(crate) fn boolean(a: &Body, b: &Body, op: BooleanOp) -> Result<Option<Body>> {
|
||||
usable("SmApiBoolean", &[a, b])?;
|
||||
let d = run("SmApiBoolean", |k| {
|
||||
let mut c = copies(k, &[a, b])?.into_iter();
|
||||
let (Some(ca), Some(cb)) = (c.next(), c.next()) else {
|
||||
return Err(SolidError::invalid_input("smlib_boolean", "no operand"));
|
||||
};
|
||||
let solid_in = p2::is_manifold_solid(&ca)? && p2::is_manifold_solid(&cb)?;
|
||||
let r = sm::boolean(k, ca, cb, operation(op))?;
|
||||
finish(r.result, solid_in, true)
|
||||
})?;
|
||||
result(d, Ok(None))
|
||||
}
|
||||
|
||||
/// `smlib_boolean_union_all`: copies of `bodies` unioned pairwise, level by level (an odd one
|
||||
/// carried to the next level as it is), each union `SmApiBoolean`'s; then `finish` with
|
||||
/// non-manifold results allowed. An empty result is [`Error::Empty`].
|
||||
pub(crate) fn union_all(bodies: &[&Body]) -> Result<Body> {
|
||||
if bodies.is_empty() || bodies.len() > crate::MAX_UNION_BREPS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
usable("smlib_boolean_union_all", bodies)?;
|
||||
let d = run("smlib_boolean_union_all", |k| {
|
||||
let mut level = copies(k, bodies)?;
|
||||
let solid_in = all_solid(&level)?;
|
||||
while level.len() > 1 {
|
||||
let mut next = Vec::with_capacity(level.len().div_ceil(2));
|
||||
let mut it = level.into_iter();
|
||||
while let Some(a) = it.next() {
|
||||
match it.next() {
|
||||
Some(b) => next.push(sm::boolean(k, a, b, BooleanOperation::Union)?.result),
|
||||
None => next.push(a),
|
||||
}
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
match level.pop() {
|
||||
Some(r) => finish(r, solid_in, true),
|
||||
None => Ok(Done::Empty),
|
||||
}
|
||||
})?;
|
||||
result(d, Err(Error::Empty))?.ok_or(Error::Empty)
|
||||
}
|
||||
|
||||
/// `smlib_boolean2d`: `SmApiBoolean2d` (`SmPrimitiveCreation::Boolean2D`) on copies of `a` and
|
||||
/// `b`, sheets of planar faces on one plane; `None` when either has no face (where
|
||||
/// `SmApiBoolean2d` succeeds without a result) or the result is empty.
|
||||
pub(crate) fn boolean2d(a: &Body, b: &Body, op: BooleanOp) -> Result<Option<Body>> {
|
||||
usable("SmApiBoolean2d", &[a, b])?;
|
||||
let d = run("SmApiBoolean2d", |k| {
|
||||
let mut c = copies(k, &[a, b])?.into_iter();
|
||||
let (Some(ca), Some(cb)) = (c.next(), c.next()) else {
|
||||
return Err(SolidError::invalid_input("smlib_boolean2d", "no operand"));
|
||||
};
|
||||
if num_faces(&ca) == 0 || num_faces(&cb) == 0 {
|
||||
return Ok(Done::Empty);
|
||||
}
|
||||
let kind = match op {
|
||||
BooleanOp::Union => Boolean2d::Union,
|
||||
BooleanOp::Subtract => Boolean2d::Difference,
|
||||
BooleanOp::Intersect => Boolean2d::Intersection,
|
||||
};
|
||||
match sm::boolean_2d(k, ca, cb, kind)? {
|
||||
Boolean2dOutcome::Result(r) => finish(r, false, false),
|
||||
Boolean2dOutcome::Unassigned { .. } => Ok(Done::Empty),
|
||||
}
|
||||
})?;
|
||||
result(d, Ok(None))
|
||||
}
|
||||
|
||||
/// `smlib_imprint`: `SmApiBooleanWithOptions(SM_BO_IMPRINT)` on copies of `body` and `tool`,
|
||||
/// imprinting and keeping the tool (cookie cutter off, imprinting on, post-process off,
|
||||
/// imprint-and-classify off, keep other on). `None` when no face was cut
|
||||
/// (`SMLIB_ERR_NO_CUT`); an empty result is [`Error::Empty`]; the same body twice is
|
||||
/// [`Error::Argument`].
|
||||
pub(crate) fn imprint(body: &Body, tool: &Body) -> Result<Option<Body>> {
|
||||
if std::ptr::eq(body, tool) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
usable("SmApiBooleanWithOptions", &[body, tool])?;
|
||||
let d = run("SmApiBooleanWithOptions", |k| {
|
||||
let mut c = copies(k, &[body, tool])?.into_iter();
|
||||
let (Some(a), Some(t)) = (c.next(), c.next()) else {
|
||||
return Err(SolidError::invalid_input("smlib_imprint", "no operand"));
|
||||
};
|
||||
let before = num_faces(&a);
|
||||
let options = MergeOptions::new(false, true, false, false, true);
|
||||
let r = sm::boolean_with_options(k, a, t, BooleanOperation::Imprint, options)?.result;
|
||||
let after = num_faces(&r);
|
||||
Ok(if after == 0 {
|
||||
Done::Empty
|
||||
} else if after == before {
|
||||
Done::NoCut
|
||||
} else {
|
||||
Done::Body(Box::new(r))
|
||||
})
|
||||
})?;
|
||||
match d {
|
||||
Done::Empty => Err(Error::Empty),
|
||||
d => result(d, Err(Error::Empty)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Booleans on solidcore, against analytic volumes: every result exports to a record that
|
||||
//! validates and counts the solids it should. With SMLib built, the same case on the C++ kernel
|
||||
//! gives the same volume, counts, faces by surface kind and solids (parity only: the expected
|
||||
//! values are the analytic ones).
|
||||
//!
|
||||
//! Curved operands are built on solidcore itself.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::PI;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::mass::integrator::properties::brep_area;
|
||||
use solidcore::primitive::primitive_creation::p1::create_rectangle;
|
||||
|
||||
use super::boolean::{boolean, boolean2d, edge_closes, imprint, union_all};
|
||||
use super::{Body, counts, make_box, make_cylinder, make_sphere, run, solid_count, to_arrays, volume, with_body};
|
||||
use crate::export::native;
|
||||
use crate::{BooleanOp, Error};
|
||||
|
||||
/// A case longer than this is a hang.
|
||||
const TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// An operand.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Shape {
|
||||
Box([f64; 3], [f64; 3]),
|
||||
Cylinder([f64; 3], f64, f64),
|
||||
Sphere([f64; 3], f64),
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn core(self) -> Body {
|
||||
match self {
|
||||
Shape::Box(o, s) => make_box(o, s),
|
||||
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h),
|
||||
Shape::Sphere(c, r) => make_sphere(c, r),
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a case does.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Op {
|
||||
Boolean(Shape, Shape, BooleanOp),
|
||||
UnionAll(Vec<Shape>),
|
||||
Imprint(Shape, Shape),
|
||||
}
|
||||
|
||||
/// A case: the operation, its analytic volume (`None`: the result is empty), the faces it
|
||||
/// must have where they are known analytically, and its solids (`smlib_brep_solid_count`).
|
||||
struct Case {
|
||||
op: Op,
|
||||
volume: Option<f64>,
|
||||
faces: Option<usize>,
|
||||
solids: usize,
|
||||
}
|
||||
|
||||
const A: Shape = Shape::Box([0.0; 3], [10.0; 3]);
|
||||
|
||||
fn case(name: &str) -> Case {
|
||||
let plain = |op, volume: Option<f64>, faces| Case { op, volume, faces, solids: 1 };
|
||||
match name {
|
||||
// |A| + |B| - |A ∩ B|: two 10 mm cubes overlapping in a 5 mm cube.
|
||||
"union_overlapping" => plain(Op::Boolean(A, Shape::Box([5.0; 3], [10.0; 3]), BooleanOp::Union), Some(1000.0 + 1000.0 - 125.0), Some(12)),
|
||||
// Two 10 mm cubes 10 mm apart: one body of two solids, 12 faces.
|
||||
"union_disjoint" => {
|
||||
Case { solids: 2, ..plain(Op::Boolean(A, Shape::Box([20.0, 0.0, 0.0], [10.0; 3]), BooleanOp::Union), Some(2000.0), Some(12)) }
|
||||
}
|
||||
"intersect_overlapping" => plain(Op::Boolean(A, Shape::Box([5.0; 3], [10.0; 3]), BooleanOp::Intersect), Some(125.0), Some(6)),
|
||||
// A slot 5 mm wide and 5 mm deep from the top, right through a 30 x 20 x 10 block.
|
||||
"subtract_through_slot" => plain(
|
||||
Op::Boolean(Shape::Box([0.0; 3], [30.0, 20.0, 10.0]), Shape::Box([10.0, -1.0, 5.0], [5.0, 22.0, 6.0]), BooleanOp::Subtract),
|
||||
Some(6000.0 - 5.0 * 20.0 * 5.0),
|
||||
Some(10),
|
||||
),
|
||||
// A notch whose tool is flush with the block's front, back and top faces.
|
||||
"subtract_flush_notch" => plain(
|
||||
Op::Boolean(Shape::Box([0.0; 3], [30.0, 20.0, 10.0]), Shape::Box([10.0, 0.0, 5.0], [10.0, 20.0, 5.0]), BooleanOp::Subtract),
|
||||
Some(6000.0 - 10.0 * 20.0 * 5.0),
|
||||
Some(10),
|
||||
),
|
||||
// A 4 mm cube inside a 10 mm one: a void, still one solid.
|
||||
"subtract_inside_void" => plain(Op::Boolean(A, Shape::Box([3.0; 3], [4.0; 3]), BooleanOp::Subtract), Some(1000.0 - 64.0), Some(12)),
|
||||
// abc - πr²c: a 3 mm hole through a 20 x 20 x 10 block.
|
||||
"subtract_cylinder_hole" => plain(
|
||||
Op::Boolean(Shape::Box([0.0; 3], [20.0, 20.0, 10.0]), Shape::Cylinder([10.0, 10.0, -1.0], 3.0, 12.0), BooleanOp::Subtract),
|
||||
Some(4000.0 - PI * 9.0 * 10.0),
|
||||
None,
|
||||
),
|
||||
// Nine 10 mm cubes, each touching its neighbours face to face.
|
||||
"union_all_grid" => plain(
|
||||
Op::UnionAll((0..9).map(|i| Shape::Box([f64::from(i % 3) * 10.0, f64::from(i / 3) * 10.0, 0.0], [10.0; 3])).collect()),
|
||||
Some(9000.0),
|
||||
None,
|
||||
),
|
||||
"intersect_disjoint" => plain(Op::Boolean(A, Shape::Box([20.0; 3], [10.0; 3]), BooleanOp::Intersect), None, None),
|
||||
// A 10 mm sphere less a cap of height h = 5: 4/3 πr³ - πh²(3r - h)/3.
|
||||
// (solidcore#1431, brep_volume of a whole sphere failing at 1e-8, does not touch the cut
|
||||
// sphere: it measures at 1e-8.)
|
||||
"sphere_minus_box" => plain(
|
||||
Op::Boolean(Shape::Sphere([0.0; 3], 10.0), Shape::Box([-20.0, -20.0, 5.0], [40.0, 40.0, 20.0]), BooleanOp::Subtract),
|
||||
Some(4.0 / 3.0 * PI * 1000.0 - PI * 25.0 * 25.0 / 3.0),
|
||||
Some(2),
|
||||
),
|
||||
// A 4 mm square tool through the cube's top face: that face is cut in two (a square and
|
||||
// the ring round it), nothing else changes.
|
||||
"imprint_top_face" => plain(Op::Imprint(A, Shape::Box([3.0, 3.0, 8.0], [4.0; 3])), Some(1000.0), Some(7)),
|
||||
_ => panic!("{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
/// What a kernel's result measures.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Measured {
|
||||
volume: f64,
|
||||
counts: [usize; 3],
|
||||
/// The record's faces by surface kind.
|
||||
kinds: BTreeMap<String, usize>,
|
||||
solids: usize,
|
||||
}
|
||||
|
||||
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for f in &rec.faces {
|
||||
let v = serde_json::to_value(&f.surface).unwrap();
|
||||
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The case on solidcore: `None` for an empty result.
|
||||
fn on_core(c: &Case) -> crate::Result<Option<Body>> {
|
||||
match &c.op {
|
||||
Op::Boolean(a, b, op) => boolean(&a.core(), &b.core(), *op),
|
||||
Op::UnionAll(shapes) => {
|
||||
let bodies: Vec<Body> = shapes.iter().map(|s| s.core()).collect();
|
||||
union_all(&bodies.iter().collect::<Vec<_>>()).map(Some)
|
||||
}
|
||||
Op::Imprint(a, t) => imprint(&a.core(), &t.core()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
|
||||
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
match rx.recv_timeout(TIMEOUT) {
|
||||
Ok(v) => v,
|
||||
// The case panicked: its own message is the failure.
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
Ok(()) => panic!("{name}: no result"),
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the case on solidcore and checks it against the analytic answer; what it measured.
|
||||
fn solidcore_case(name: &'static str) -> Option<Measured> {
|
||||
timed(name, move || {
|
||||
let c = case(name);
|
||||
let r = on_core(&c).unwrap_or_else(|e| panic!("{name}: solidcore failed: {e}"));
|
||||
let (Some(body), Some(expected)) = (&r, c.volume) else {
|
||||
assert!(r.is_none() && c.volume.is_none(), "{name}: solidcore gave {}, expected {:?}", r.is_some(), c.volume);
|
||||
return None;
|
||||
};
|
||||
let v = volume(body, 1e-8).unwrap_or_else(|e| panic!("{name}: volume: {e}"));
|
||||
assert!(rel(v, expected) < 1e-9, "{name}: solidcore volume {v} vs analytic {expected}");
|
||||
let n = counts(body).unwrap();
|
||||
if let Some(f) = c.faces {
|
||||
assert_eq!(n[0], f, "{name}: faces {n:?}");
|
||||
}
|
||||
let solids = solid_count(body).unwrap_or_else(|e| panic!("{name}: solid count: {e}"));
|
||||
assert_eq!(solids, c.solids, "{name}: solids");
|
||||
let (rec, _) = native(&to_arrays(body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
Some(Measured { volume: v, counts: n, kinds: kinds(&rec), solids })
|
||||
})
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, called directly (whatever the choice): what it measured.
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Option<Measured> {
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
let make = |s: Shape| -> Brep {
|
||||
match s {
|
||||
Shape::Box(o, z) => ffi::make_box(&o, &z),
|
||||
Shape::Cylinder(o, r, h) => ffi::make_cylinder(&o, r, h),
|
||||
Shape::Sphere(c, r) => ffi::make_sphere(&c, r),
|
||||
}
|
||||
.map(Brep::made)
|
||||
.unwrap()
|
||||
};
|
||||
let bool_op = |op| match op {
|
||||
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
|
||||
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
|
||||
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
|
||||
};
|
||||
let c = case(name);
|
||||
let r = match &c.op {
|
||||
Op::Boolean(a, b, op) => ffi::ops::boolean(make(*a).cpp().unwrap(), make(*b).cpp().unwrap(), bool_op(*op)),
|
||||
Op::UnionAll(shapes) => {
|
||||
let bodies: Vec<Brep> = shapes.iter().map(|s| make(*s)).collect();
|
||||
let handles: Vec<&ffi::Handle> = bodies.iter().map(|b| b.cpp().unwrap()).collect();
|
||||
ffi::ops::boolean_union_all(&handles)
|
||||
}
|
||||
Op::Imprint(a, t) => ffi::ops::imprint(make(*a).cpp().unwrap(), make(*t).cpp().unwrap()),
|
||||
};
|
||||
let h = match r {
|
||||
Ok(h) => h,
|
||||
Err(ffi::ERR_EMPTY) => return None,
|
||||
Err(s) => panic!("{name}: C++ failed: {:?}", crate::error(s)),
|
||||
};
|
||||
let v = ffi::volume(&h, 1e-8).unwrap();
|
||||
let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let solids = usize::try_from(ffi::ops::solid_count(&h).unwrap()).unwrap();
|
||||
let b = Brep::made(h);
|
||||
let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Some(Measured { volume: v, counts: n, kinds: kinds(&rec), solids })
|
||||
}
|
||||
|
||||
/// solidcore and C++ agree: volume within 1e-9, the same counts and faces by kind.
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &'static str) {
|
||||
let ours = solidcore_case(name);
|
||||
let cpp = cpp_case(name);
|
||||
match (ours, cpp) {
|
||||
(None, None) => {}
|
||||
(Some(a), Some(b)) => {
|
||||
assert!(rel(a.volume, b.volume) < 1e-9, "{name}: solidcore {} vs C++ {}", a.volume, b.volume);
|
||||
assert_eq!(a.counts, b.counts, "{name}: face, edge, vertex counts (solidcore vs C++)");
|
||||
assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)");
|
||||
assert_eq!(a.solids, b.solids, "{name}: solids (solidcore vs C++)");
|
||||
}
|
||||
(a, b) => panic!("{name}: solidcore {a:?} vs C++ {b:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_analytic_volume() {
|
||||
super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
cases!(
|
||||
union_overlapping,
|
||||
union_disjoint,
|
||||
intersect_overlapping,
|
||||
subtract_through_slot,
|
||||
subtract_flush_notch,
|
||||
subtract_inside_void,
|
||||
subtract_cylinder_hole,
|
||||
union_all_grid,
|
||||
intersect_disjoint,
|
||||
sphere_minus_box,
|
||||
imprint_top_face,
|
||||
);
|
||||
|
||||
/// A `w` x `h` planar sheet on z = 0 from `(x, y)`, solidcore's `CreateRectangle`.
|
||||
fn rectangle(x: f64, y: f64, w: f64, h: f64) -> Body {
|
||||
let frame = Axis2Placement::new(Vec3::new(x, y, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
|
||||
run("create_rectangle", |k| create_rectangle(k, 0.0, w, h, &frame)).map(Body::new).unwrap()
|
||||
}
|
||||
|
||||
fn area(b: &Body) -> f64 {
|
||||
with_body("brep_area", b, |k, b| brep_area(k, b, 1e-8)).unwrap()
|
||||
}
|
||||
|
||||
/// Two 10 mm squares overlapping in a 5 mm one, as regions of their plane.
|
||||
#[test]
|
||||
fn boolean2d_combines_overlapping_squares() {
|
||||
for (op, expected) in [(BooleanOp::Union, 175.0), (BooleanOp::Intersect, 25.0), (BooleanOp::Subtract, 75.0)] {
|
||||
let (a, b) = (rectangle(0.0, 0.0, 10.0, 10.0), rectangle(5.0, 5.0, 10.0, 10.0));
|
||||
let r = timed("boolean2d", move || boolean2d(&a, &b, op).map(|r| r.map(|r| (area(&r), counts(&r).unwrap()))));
|
||||
let (got, n) = r.unwrap_or_else(|e| panic!("{op:?}: {e}")).unwrap_or_else(|| panic!("{op:?}: empty"));
|
||||
assert!(rel(got, expected) < 1e-9, "{op:?}: area {got} vs {expected}");
|
||||
assert_eq!(n[0], 1, "{op:?}: {n:?}");
|
||||
}
|
||||
let (a, far) = (rectangle(0.0, 0.0, 10.0, 10.0), rectangle(20.0, 0.0, 10.0, 10.0));
|
||||
assert!(boolean2d(&a, &far, BooleanOp::Intersect).unwrap().is_none(), "disjoint squares intersected");
|
||||
}
|
||||
|
||||
/// `closed_nonmanifold`'s test of an edge, as the capi's: an edge whose wire or edgeuse query
|
||||
/// fails makes the body not closed non-manifold (false), never an error that fails the boolean.
|
||||
#[test]
|
||||
fn closed_nonmanifold_takes_a_failed_edge_query_as_false() {
|
||||
let failed = || solidcore::SolidError::null_pointer("GetEdgeuses");
|
||||
assert!(edge_closes(Ok(false), Ok(4)));
|
||||
assert!(edge_closes(Ok(false), Ok(8)));
|
||||
assert!(!edge_closes(Ok(false), Ok(2)));
|
||||
assert!(!edge_closes(Ok(false), Ok(6)));
|
||||
assert!(!edge_closes(Ok(true), Ok(4)));
|
||||
assert!(!edge_closes(Ok(false), Err(failed())));
|
||||
assert!(!edge_closes(Err(failed()), Ok(4)));
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes touching along one edge, joined: a closed body whose two solids meet along
|
||||
/// that edge (four edgeuses round it), 2000 mm³.
|
||||
#[test]
|
||||
fn cubes_touching_along_an_edge_join_closed_nonmanifold() {
|
||||
let r = timed("edge_touching", || {
|
||||
let a = Shape::Box([0.0; 3], [10.0; 3]).core();
|
||||
let b = Shape::Box([10.0, 10.0, 0.0], [10.0; 3]).core();
|
||||
boolean(&a, &b, BooleanOp::Union).map(|r| r.map(|r| (volume(&r, 1e-8).unwrap(), solid_count(&r).unwrap())))
|
||||
});
|
||||
let (v, solids) = r.unwrap().unwrap();
|
||||
assert!(rel(v, 2000.0) < 1e-9, "volume {v}");
|
||||
assert_eq!(solids, 2);
|
||||
}
|
||||
|
||||
/// The capi's argument checks: the same body imprinted on itself, an empty union.
|
||||
#[test]
|
||||
fn hostile_operands_are_refused() {
|
||||
let a = A.core();
|
||||
assert_eq!(imprint(&a, &a).err(), Some(Error::Argument));
|
||||
assert_eq!(union_all(&[]).err(), Some(Error::Argument));
|
||||
}
|
||||
|
||||
/// A tool that meets no face cuts none.
|
||||
#[test]
|
||||
fn imprint_of_a_far_tool_is_none() {
|
||||
let r = timed("imprint_far", || imprint(&A.core(), &Shape::Box([20.0; 3], [1.0; 3]).core()).map(|r| r.is_none()));
|
||||
assert!(r.unwrap());
|
||||
}
|
||||
|
||||
/// An operand is never changed: the same body twice, and again after.
|
||||
#[test]
|
||||
fn operands_stay_as_they_were() {
|
||||
let r = timed("operands", || {
|
||||
let a = A.core();
|
||||
let b = Shape::Box([5.0; 3], [10.0; 3]).core();
|
||||
let u = boolean(&a, &b, BooleanOp::Union).map(|r| r.is_some());
|
||||
let same = boolean(&a, &a, BooleanOp::Union).map(|r| r.map(|r| volume(&r, 1e-8)));
|
||||
(u, same, counts(&a).unwrap(), volume(&a, 1e-8).unwrap(), counts(&b).unwrap())
|
||||
});
|
||||
let (u, same, ca, va, cb) = r;
|
||||
assert!(u.unwrap());
|
||||
assert_eq!(ca, [6, 12, 8]);
|
||||
assert_eq!(cb, [6, 12, 8]);
|
||||
assert!(rel(va, 1000.0) < 1e-12, "{va}");
|
||||
let v = same.unwrap().unwrap().unwrap();
|
||||
assert!(rel(v, 1000.0) < 1e-9, "A ∪ A: {v}");
|
||||
}
|
||||
|
||||
/// `moved`: a closest-point query that fails, even with an error that is no SMLib status (an edge
|
||||
/// without its curve here), counts as off the piece, as `SmApiEdgeClosestPoint` failing does in the
|
||||
/// capi (smlib_history.cpp:246): the face is Modified, the operation stands.
|
||||
#[test]
|
||||
fn a_failed_closest_point_counts_as_off_the_piece() {
|
||||
use solidcore::query::api_queries::edge_closest_point;
|
||||
use solidcore::topology::edge;
|
||||
let body = A.core();
|
||||
super::with_body("moved", &body, |k, b| {
|
||||
let e = b.edge_list()[0];
|
||||
let (Some(s), Some(f)) = edge::start_end_vertices(b, e)? else { panic!("an edge with no ends") };
|
||||
let (s, f) = (b.vertex(s)?.point, b.vertex(f)?.point);
|
||||
let at = [s, f, s + (f - s) * 0.5];
|
||||
let tol = b.tolerance();
|
||||
assert!(!super::history::moved(k, b, &[(e, &at)], tol)?, "still where it lay");
|
||||
b.edge_mut(e)?.curve = None;
|
||||
assert!(edge_closest_point(k, b, e, at[2]).is_err_and(|e| e.sm_status().is_none()), "fails with no status");
|
||||
assert!(super::history::moved(k, b, &[(e, &at)], tol)?, "off the piece");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Face and edge tags (`Brep::tagged`) through operations on solidcore, as on C++: each
|
||||
/// operand is tagged on the C++ kernel, its solidcore body converted from that and tagged the same
|
||||
/// way there, and the two results' histories ([`Raw`], each in its own kernel's face and edge
|
||||
/// order) must be the same.
|
||||
#[cfg(feature = "native")]
|
||||
mod history {
|
||||
use crate::history::Raw;
|
||||
use crate::{BooleanOp, Brep, ffi};
|
||||
|
||||
fn tagged_box(origin: [f64; 3], size: [f64; 3], operand: usize) -> Brep {
|
||||
crate::init().unwrap();
|
||||
Brep::made(ffi::make_box(&origin, &size).unwrap()).tagged(operand).unwrap()
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes overlapping in a 5 mm one (the capi's `overlapping_boxes_union`): 12
|
||||
/// faces, each from one face of one box, six from each.
|
||||
fn check(h: &Raw) {
|
||||
assert_eq!(h.faces.len(), 12, "{h:?}");
|
||||
assert!(h.faces.iter().all(|f| f.origins.len() == 1), "a face without exactly one origin: {h:?}");
|
||||
for operand in [0, 1] {
|
||||
assert_eq!(h.faces.iter().filter(|f| f.origins[0].0 == operand).count(), 6, "operand {operand}: {h:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_cpp_boolean() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (tagged_box([0.0; 3], [10.0; 3], 0), tagged_box([5.0; 3], [10.0; 3], 1));
|
||||
let r = Brep::made(ffi::ops::boolean(a.cpp().unwrap(), b.cpp().unwrap(), ffi::ops::BOOLEAN_UNION).unwrap());
|
||||
check(&r.history().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_solidcore_boolean() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (tagged_box([0.0; 3], [10.0; 3], 0), tagged_box([5.0; 3], [10.0; 3], 1));
|
||||
let r = super::boolean(a.core().unwrap(), b.core().unwrap(), BooleanOp::Union).unwrap().unwrap();
|
||||
check(&Brep::made_core(r).history().unwrap());
|
||||
}
|
||||
|
||||
/// `a` op `b` on both kernels: the same history.
|
||||
fn parity(a: &Brep, b: &Brep, op: BooleanOp) {
|
||||
let code = match op {
|
||||
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
|
||||
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
|
||||
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
|
||||
};
|
||||
let cpp = Brep::made(ffi::ops::boolean(a.cpp().unwrap(), b.cpp().unwrap(), code).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::boolean(a.core().unwrap(), b.core().unwrap(), op).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlapping_union_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
parity(&tagged_box([0.0; 3], [10.0; 3], 0), &tagged_box([5.0; 3], [10.0; 3], 1), BooleanOp::Union);
|
||||
}
|
||||
|
||||
/// A slot across the top of a 20 mm cube: the top is cut in two, the slot's walls and floor
|
||||
/// come from the tool.
|
||||
#[test]
|
||||
fn a_slot_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (tagged_box([0.0; 3], [20.0; 3], 0), tagged_box([5.0, -1.0, 10.0], [10.0, 22.0, 15.0], 1));
|
||||
parity(&a, &b, BooleanOp::Subtract);
|
||||
}
|
||||
|
||||
/// Two boxes side by side, sharing the face x = 10 and their top and bottom planes: faces
|
||||
/// found lying on each other's (SMLib's coincidences and merges) stand for both.
|
||||
#[test]
|
||||
fn boxes_side_by_side_have_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (tagged_box([0.0; 3], [10.0; 3], 0), tagged_box([10.0, 0.0, 0.0], [10.0; 3], 1));
|
||||
parity(&a, &b, BooleanOp::Union);
|
||||
}
|
||||
|
||||
/// A box sharing one corner region with another: the intersection keeps three faces of each.
|
||||
#[test]
|
||||
fn an_intersection_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
parity(&tagged_box([0.0; 3], [10.0; 3], 0), &tagged_box([5.0; 3], [10.0; 3], 1), BooleanOp::Intersect);
|
||||
}
|
||||
|
||||
/// `SmApiBrepCopy` (the copy every solidcore operation starts from) keeps every tag: the
|
||||
/// copy's history is the operand's own, every face Kept, as on C++.
|
||||
#[test]
|
||||
fn tags_cross_a_copy() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = tagged_box([0.0; 3], [10.0, 20.0, 30.0], 0);
|
||||
// Converted (and tagged) before the kernel's lock is taken: the conversion takes it too.
|
||||
let body = a.core().unwrap();
|
||||
let copy = super::run("copy", |k| {
|
||||
let g = body.0.lock().unwrap();
|
||||
solidcore::topology::brep::p3::brep_copy(k, &g)
|
||||
})
|
||||
.unwrap();
|
||||
let core = Brep::made_core(super::Body::new(copy)).history().unwrap();
|
||||
let cpp = Brep::made(ffi::copy(a.cpp().unwrap()).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
assert!(core.faces.iter().all(|f| f.change == solidcraft_geom::brep::history::Change::Kept), "{core:?}");
|
||||
}
|
||||
|
||||
/// A post standing on the top of a cube, imprinted on it: the top is split along the
|
||||
/// post's foot, the other faces are as they were.
|
||||
#[test]
|
||||
fn an_imprint_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (tagged_box([0.0; 3], [20.0; 3], 0), tagged_box([5.0, 5.0, 10.0], [10.0, 10.0, 20.0], 1));
|
||||
let cpp = Brep::made(ffi::ops::imprint(a.cpp().unwrap(), b.cpp().unwrap()).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::imprint(a.core().unwrap(), b.core().unwrap()).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
/// A 10 mm wide bar laid across the top of a 40 x 30 x 20 block, overhanging its front and
|
||||
/// back, imprinted on it: the bar's sides cut the top in three, and cut the top edges of the
|
||||
/// faces y = 0 and y = 30 only by a vertex each (where they cross them at x = 10 and x = 20).
|
||||
/// Those faces are still all of themselves, their boundaries where they were: Kept, as are
|
||||
/// x = 0, x = 40 and the bottom (the fork's 1c7accd; before, y = 0 and y = 30 were Modified).
|
||||
#[test]
|
||||
fn a_face_whose_edge_is_only_cut_by_a_vertex_is_kept() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use solidcraft_geom::brep::history::Change;
|
||||
let (a, b) = (tagged_box([0.0; 3], [40.0, 30.0, 20.0], 0), tagged_box([10.0, -5.0, 20.0], [10.0, 40.0, 10.0], 1));
|
||||
let cpp = Brep::made(ffi::ops::imprint(a.cpp().unwrap(), b.cpp().unwrap()).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::imprint(a.core().unwrap(), b.core().unwrap()).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
let of_a = |h: &Raw, c: Change| h.faces.iter().filter(|f| f.origins.iter().all(|o| o.0 == 0) && f.change == c).count();
|
||||
for h in [&core, &cpp] {
|
||||
assert_eq!(of_a(h, Change::Kept), 5, "{h:?}");
|
||||
assert_eq!(of_a(h, Change::Modified), 0, "{h:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 10 mm post standing on a 20 mm cube's top against its x = 0 side, imprinted on it: the
|
||||
/// post's foot cuts the top's edge along x = 0 by two vertices. Each piece of that edge is a
|
||||
/// split child, standing for the edge and lying where it lay (the capi's Split gives an untagged
|
||||
/// child the split edge's `at`), so the side x = 0 is still all of itself and Kept.
|
||||
#[test]
|
||||
fn an_imprint_split_edge_lies_where_the_edge_lay() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use solidcraft_geom::brep::history::Change;
|
||||
let (a, b) = (tagged_box([0.0; 3], [20.0; 3], 0), tagged_box([0.0, 5.0, 20.0], [10.0; 3], 1));
|
||||
let cpp = Brep::made(ffi::ops::imprint(a.cpp().unwrap(), b.cpp().unwrap()).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::imprint(a.core().unwrap(), b.core().unwrap()).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
let of_a = |h: &Raw, c: Change| h.faces.iter().filter(|f| f.origins.iter().all(|o| o.0 == 0) && f.change == c).count();
|
||||
for h in [&core, &cpp] {
|
||||
// The top's edge along x = 0 is in three pieces, each standing for it alone.
|
||||
let pieces = |e: usize| h.edges.iter().filter(|x| x.origins == [(0, e)]).count();
|
||||
assert!((0..12).any(|e| pieces(e) == 3), "{h:?}");
|
||||
assert_eq!(of_a(h, Change::Kept), 5, "{h:?}");
|
||||
assert_eq!(of_a(h, Change::Modified), 0, "{h:?}");
|
||||
assert_eq!(of_a(h, Change::Split), 2, "{h:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 10 mm post standing on a 20 mm cube's top, subtracted from it: the post only touches the
|
||||
/// top, which the boolean copies the post's foot onto (`SmAttribute::Copy`). SMLib's Copy
|
||||
/// leaves a target that already has a tag alone, so the top stands for the cube's top only.
|
||||
#[test]
|
||||
fn a_copy_onto_a_tagged_face_keeps_its_own_tag() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use solidcraft_geom::brep::history::Change;
|
||||
let (a, b) = (tagged_box([0.0; 3], [20.0; 3], 0), tagged_box([5.0, 5.0, 20.0], [10.0; 3], 1));
|
||||
parity(&a, &b, BooleanOp::Subtract);
|
||||
let core = Brep::made_core(super::boolean(a.core().unwrap(), b.core().unwrap(), BooleanOp::Subtract).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.faces.len(), 6, "{core:?}");
|
||||
assert!(core.faces.iter().all(|f| f.change == Change::Kept && f.origins.len() == 1 && f.origins[0].0 == 0), "{core:?}");
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes side by side sharing the face x = 10, the second subtracted from the first:
|
||||
/// the second's edge on that face is copied onto the first's edge it lies on, which keeps its
|
||||
/// own tag (SMLib's Copy), as on C++.
|
||||
#[test]
|
||||
fn a_copy_onto_a_tagged_edge_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
parity(&tagged_box([0.0; 3], [10.0; 3], 0), &tagged_box([10.0, 0.0, 0.0], [10.0; 3], 1), BooleanOp::Subtract);
|
||||
}
|
||||
|
||||
fn tagged_cylinder(origin: [f64; 3], radius: f64, height: f64, operand: usize) -> Brep {
|
||||
crate::init().unwrap();
|
||||
Brep::made(ffi::make_cylinder(&origin, radius, height).unwrap()).tagged(operand).unwrap()
|
||||
}
|
||||
|
||||
/// A 40 x 40 x 10 plate with a round hole r 5 at (20, 20), made before tagging: its hole's rims
|
||||
/// are whole circles, edges closed on themselves.
|
||||
fn holed_plate(operand: usize) -> Brep {
|
||||
crate::init().unwrap();
|
||||
let plate = ffi::make_box(&[0.0; 3], &[40.0, 40.0, 10.0]).unwrap();
|
||||
let rod = ffi::make_cylinder(&[20.0, 20.0, -5.0], 5.0, 20.0).unwrap();
|
||||
Brep::made(ffi::ops::boolean(&plate, &rod, ffi::ops::BOOLEAN_SUBTRACT).unwrap()).tagged(operand).unwrap()
|
||||
}
|
||||
|
||||
/// Faces of operand 0 with this change, in a history.
|
||||
fn of_first(h: &Raw, c: solidcraft_geom::brep::history::Change) -> usize {
|
||||
h.faces.iter().filter(|f| f.origins.iter().all(|o| o.0 == 0) && !f.origins.is_empty() && f.change == c).count()
|
||||
}
|
||||
|
||||
/// A hole through a block: the tool's and the block's faces meet along whole circles.
|
||||
#[test]
|
||||
fn a_round_hole_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
parity(&tagged_box([0.0; 3], [40.0, 40.0, 10.0], 0), &tagged_cylinder([20.0, 20.0, -5.0], 5.0, 20.0, 1), BooleanOp::Subtract);
|
||||
}
|
||||
|
||||
/// A corner cut far from the hole: the hole's wall keeps its two whole circles, each a closed
|
||||
/// edge still where it lay (`moved`'s `open.is_empty()`), so it is Kept on both kernels.
|
||||
#[test]
|
||||
fn a_face_bounded_by_closed_edges_is_kept() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use solidcraft_geom::brep::history::Change;
|
||||
let (a, b) = (holed_plate(0), tagged_box([35.0, 35.0, -5.0], [10.0, 10.0, 20.0], 1));
|
||||
let cpp = Brep::made(ffi::ops::boolean(a.cpp().unwrap(), b.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::boolean(a.core().unwrap(), b.core().unwrap(), BooleanOp::Subtract).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
// The hole's wall and the two sides the corner misses.
|
||||
assert_eq!(of_first(&core, Change::Kept), 3, "{core:?}");
|
||||
}
|
||||
|
||||
/// A bar laid on the plate over the hole, imprinted: the bar's sides cross the hole's upper rim
|
||||
/// and cut that closed edge by vertices; the top is split, the wall's rim stays where it lay.
|
||||
#[test]
|
||||
fn an_imprint_cutting_a_closed_edge_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (holed_plate(0), tagged_box([-5.0, 18.0, 10.0], [50.0, 4.0, 5.0], 1));
|
||||
let cpp = Brep::made(ffi::ops::imprint(a.cpp().unwrap(), b.cpp().unwrap()).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::imprint(a.core().unwrap(), b.core().unwrap()).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
/// A cylinder and a sphere cut by a slab across their seams (each kernel's angle 0, along +x):
|
||||
/// each curved face is split in two across its seam.
|
||||
#[test]
|
||||
fn a_curved_face_split_across_its_seam_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use solidcraft_geom::brep::history::Change;
|
||||
let slab = || tagged_box([-20.0, -1.0, -20.0], [40.0, 2.0, 40.0], 1);
|
||||
let sphere = Brep::made(ffi::make_sphere(&[0.0, 0.0, 0.0], 7.0).unwrap()).tagged(0).unwrap();
|
||||
for (name, a) in [("cylinder", tagged_cylinder([0.0, 0.0, -5.0], 5.0, 10.0, 0)), ("sphere", sphere)] {
|
||||
parity(&a, &slab(), BooleanOp::Subtract);
|
||||
let cpp = Brep::made(ffi::ops::boolean(a.cpp().unwrap(), slab().cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap()).history().unwrap();
|
||||
assert!(of_first(&cpp, Change::Split) >= 2, "{name}: {cpp:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A chain of three overlapping boxes in one `union_all`.
|
||||
#[test]
|
||||
fn a_union_of_three_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b, c) = (tagged_box([0.0; 3], [10.0; 3], 0), tagged_box([5.0; 3], [10.0; 3], 1), tagged_box([10.0; 3], [10.0; 3], 2));
|
||||
let cpp = Brep::made(ffi::ops::boolean_union_all(&[a.cpp().unwrap(), b.cpp().unwrap(), c.cpp().unwrap()]).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::union_all(&[a.core().unwrap(), b.core().unwrap(), c.core().unwrap()]).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
assert_eq!(core.faces.iter().flat_map(|f| f.origins.iter().map(|o| o.0)).max(), Some(2), "{core:?}");
|
||||
}
|
||||
|
||||
/// A row of four boxes sharing their top and bottom planes, united in pairs and the pairs
|
||||
/// united: each top merged with its neighbour's stands for two faces, and the merge of those
|
||||
/// stands for all four, both sources carrying several origins.
|
||||
#[test]
|
||||
fn a_merge_of_several_origins_on_each_side_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let boxes: Vec<Brep> = (0..4u32).map(|i| tagged_box([f64::from(i) * 10.0, 0.0, 0.0], [10.0; 3], i as usize)).collect();
|
||||
let c = |x: usize, y: usize| ffi::ops::boolean(boxes[x].cpp().unwrap(), boxes[y].cpp().unwrap(), ffi::ops::BOOLEAN_UNION).unwrap();
|
||||
let (ab, cd) = (c(0, 1), c(2, 3));
|
||||
let cpp = Brep::made(ffi::ops::boolean(&ab, &cd, ffi::ops::BOOLEAN_UNION).unwrap()).history().unwrap();
|
||||
let s = |x: usize, y: usize| super::boolean(boxes[x].core().unwrap(), boxes[y].core().unwrap(), BooleanOp::Union).unwrap().unwrap();
|
||||
let (ab, cd) = (s(0, 1), s(2, 3));
|
||||
let core = Brep::made_core(super::boolean(&ab, &cd, BooleanOp::Union).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
assert!(core.faces.iter().any(|f| f.origins.len() == 4), "{core:?}");
|
||||
}
|
||||
|
||||
/// Two overlapping squares combined as regions of one plane.
|
||||
#[test]
|
||||
fn a_boolean2d_has_the_cpp_history() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::sweeps::Curve;
|
||||
let square = |x: f64, y: f64, s: f64, operand: usize| {
|
||||
crate::init().unwrap();
|
||||
let c = [[x, y, 0.0], [x + s, y, 0.0], [x + s, y + s, 0.0], [x, y + s, 0.0]];
|
||||
let l: Vec<Curve> = (0..4).map(|i| Curve::Line { start: c[i], end: c[(i + 1) % 4] }).collect();
|
||||
crate::sweeps::ON_CPP.with(|f| f.set(true));
|
||||
let b = Brep::planar_faces(&l).unwrap();
|
||||
crate::sweeps::ON_CPP.with(|f| f.set(false));
|
||||
b.tagged(operand).unwrap()
|
||||
};
|
||||
let (a, b) = (square(0.0, 0.0, 10.0, 0), square(5.0, 5.0, 10.0, 1));
|
||||
for (op, code) in [
|
||||
(BooleanOp::Union, ffi::ops::BOOLEAN_UNION),
|
||||
(BooleanOp::Subtract, ffi::ops::BOOLEAN_SUBTRACT),
|
||||
(BooleanOp::Intersect, ffi::ops::BOOLEAN_INTERSECT),
|
||||
] {
|
||||
let cpp = Brep::made(ffi::ops::boolean2d(a.cpp().unwrap(), b.cpp().unwrap(), code).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::boolean2d(a.core().unwrap(), b.core().unwrap(), op).unwrap().unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical(), "{op:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rig case booleans/2928, reduced (fork c2e3d9d): a ring, r 3..5 about (0, 10), z 0..2, unioned
|
||||
/// with a half annulus prism, r 4..5 about (e, 10), z 2..20, whose cap sits on the ring's top. Two
|
||||
/// equal circles a little apart cross; they do not coincide over a tiny edge. The stack's volume
|
||||
/// is 113 pi whatever e is.
|
||||
/// This path passes with `simple_coincidence_crossing_is_point` off too, so the test holds the
|
||||
/// result here and does not single out that flag (solidcore's own test does).
|
||||
#[test]
|
||||
fn a_ring_and_a_lip_with_walls_nearly_together() {
|
||||
use super::sweep::extrude;
|
||||
use crate::sweeps::Curve;
|
||||
use crate::sweeps::DraftCorner;
|
||||
const Z: [f64; 3] = [0.0, 0.0, 1.0];
|
||||
let up = |p: &[Curve], d| extrude(p, Z, d, 0.0, DraftCorner::default(), true).unwrap();
|
||||
let arc = |c: [f64; 3], radius, start, end| Curve::Arc { center: c, axis: Z, reference: [1.0, 0.0, 0.0], radius, start, end };
|
||||
let disc = |r| up(&[arc([0.0, 10.0, 0.0], r, 0.0, 2.0 * PI)], 2.0);
|
||||
let ring = boolean(&disc(5.0), &disc(3.0), BooleanOp::Subtract).unwrap().unwrap();
|
||||
for e in [0.0, 1e-4, 1e-3, 1e-2] {
|
||||
let c = [e, 10.0, 2.0];
|
||||
let lip = up(
|
||||
&[
|
||||
arc(c, 5.0, 0.0, PI),
|
||||
Curve::Line { start: [e - 5.0, 10.0, 2.0], end: [e - 4.0, 10.0, 2.0] },
|
||||
Curve::Arc { center: c, axis: [0.0, 0.0, -1.0], reference: [-1.0, 0.0, 0.0], radius: 4.0, start: 0.0, end: PI },
|
||||
Curve::Line { start: [e + 4.0, 10.0, 2.0], end: [e + 5.0, 10.0, 2.0] },
|
||||
],
|
||||
18.0,
|
||||
);
|
||||
let u = boolean(&ring, &lip, BooleanOp::Union).unwrap().unwrap();
|
||||
let v = volume(&u, 1e-8).unwrap();
|
||||
let report = super::check(&u, crate::check::CheckLevel::All).unwrap();
|
||||
assert!(report.is_valid(), "e {e}: {report}");
|
||||
assert_eq!(solid_count(&u).unwrap(), 1, "e {e}");
|
||||
assert!(super::is_manifold_solid(&u).unwrap(), "e {e}");
|
||||
assert!((v - 113.0 * PI).abs() < 1e-3, "e {e}: {v}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,752 @@
|
||||
//! solidcore's primitives through the flat arrays: each exports to a valid record of the right
|
||||
//! volume, the record comes back to an equal body, and (with SMLib built) the arrays are the C++
|
||||
//! kernel's own. A body with a sealed void crosses with each region's enclosing shell first,
|
||||
//! whatever order its arrays list the shells in (the capi's `PutEnclosingShellsFirst`).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcraft_geom::brep as b;
|
||||
|
||||
use super::boolean::boolean;
|
||||
use super::{Body, from_arrays, make_box, make_cone, make_cylinder, make_sphere, make_torus, to_arrays, volume};
|
||||
use crate::BooleanOp;
|
||||
use crate::export::native;
|
||||
use crate::import::{self, Arrays};
|
||||
|
||||
/// A primitive: solidcore's body and its analytic volume.
|
||||
struct Case {
|
||||
body: Body,
|
||||
volume: f64,
|
||||
}
|
||||
|
||||
fn case(name: &str) -> Case {
|
||||
let (body, volume) = match name {
|
||||
"box" => (make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]), 6000.0),
|
||||
"cylinder" => (make_cylinder([1.0, 2.0, -3.0], 5.0, 12.0), PI * 25.0 * 12.0),
|
||||
"sphere" => (make_sphere([1.0, 2.0, 3.0], 7.0), 4.0 / 3.0 * PI * 343.0),
|
||||
"cone" => (make_cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0), PI * 36.0 * 9.0 / 3.0),
|
||||
"frustum" => (make_cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0), PI * 9.0 / 3.0 * (36.0 + 12.0 + 4.0)),
|
||||
"torus" => (make_torus([1.0, -1.0, 0.5], 10.0, 3.0), 2.0 * PI * PI * 10.0 * 9.0),
|
||||
_ => panic!("{name}"),
|
||||
};
|
||||
Case { body: body.unwrap(), volume }
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
/// The record of solidcore's arrays, validated and its geometry checked.
|
||||
fn record(a: &Arrays) -> b::Brep {
|
||||
let (rec, _) = native(a).unwrap();
|
||||
rec.validate().unwrap();
|
||||
rec.check_geometry().unwrap();
|
||||
rec
|
||||
}
|
||||
|
||||
/// Every number in `a` within `tol` (relative past 1) of `b`'s, everything else equal.
|
||||
fn close(a: &serde_json::Value, b: &serde_json::Value, tol: f64, at: &str) {
|
||||
use serde_json::Value as V;
|
||||
match (a, b) {
|
||||
(V::Number(x), V::Number(y)) => {
|
||||
let (x, y) = (x.as_f64().unwrap(), y.as_f64().unwrap());
|
||||
assert!((x - y).abs() <= tol * x.abs().max(1.0), "{at}: {x} vs {y}");
|
||||
}
|
||||
(V::Array(x), V::Array(y)) => {
|
||||
assert_eq!(x.len(), y.len(), "{at}: length");
|
||||
for (i, (x, y)) in x.iter().zip(y).enumerate() {
|
||||
close(x, y, tol, &format!("{at}[{i}]"));
|
||||
}
|
||||
}
|
||||
(V::Object(x), V::Object(y)) => {
|
||||
assert_eq!(x.keys().collect::<Vec<_>>(), y.keys().collect::<Vec<_>>(), "{at}: keys");
|
||||
for (k, x) in x {
|
||||
close(x, &y[k], tol, &format!("{at}.{k}"));
|
||||
}
|
||||
}
|
||||
_ => assert_eq!(a, b, "{at}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// to_arrays gives a record that validates and whose geometry checks.
|
||||
fn exports(name: &str) {
|
||||
record(&to_arrays(&case(name).body).unwrap());
|
||||
}
|
||||
|
||||
/// solidcore's body measures its analytic volume.
|
||||
fn measures(name: &str) {
|
||||
let c = case(name);
|
||||
let v = volume(&c.body, 1e-8).unwrap();
|
||||
assert!(rel(v, c.volume) < 1e-9, "{name}: solidcore volume {v} vs {}", c.volume);
|
||||
}
|
||||
|
||||
/// record -> arrays -> from_arrays -> to_arrays -> record is the record, the volume exact.
|
||||
fn round_trips(name: &str) {
|
||||
let c = case(name);
|
||||
let rec = record(&to_arrays(&c.body).unwrap());
|
||||
let back = from_arrays(&import::arrays(&rec, true).unwrap()).unwrap();
|
||||
let again = record(&to_arrays(&back).unwrap());
|
||||
close(&serde_json::to_value(&rec).unwrap(), &serde_json::to_value(&again).unwrap(), 1e-12, name);
|
||||
let v = volume(&back, 1e-8).unwrap();
|
||||
assert!(rel(v, c.volume) < 1e-9, "{name}: {v} vs {}", c.volume);
|
||||
}
|
||||
|
||||
/// The C++ kernel's arrays of the same primitive: every id, ints exact, reals within 1e-12.
|
||||
#[cfg(feature = "native")]
|
||||
fn matches_cpp(name: &str) {
|
||||
use crate::flat::{TOLERANCE, VERTEX_TOLERANCE, WIRE_EDGE_NURBS_FORM};
|
||||
use crate::{Brep, ffi};
|
||||
// built by the C API whichever kernel is in force
|
||||
crate::init().unwrap();
|
||||
let cpp = match name {
|
||||
"box" => ffi::make_box(&[1.0, -2.0, 3.0], &[10.0, 20.0, 30.0]),
|
||||
"cylinder" => ffi::make_cylinder(&[1.0, 2.0, -3.0], 5.0, 12.0),
|
||||
"sphere" => ffi::make_sphere(&[1.0, 2.0, 3.0], 7.0),
|
||||
"cone" => ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 0.0, 9.0),
|
||||
"frustum" => ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 2.0, 9.0),
|
||||
"torus" => ffi::make_torus(&[1.0, -1.0, 0.5], 10.0, 3.0),
|
||||
_ => panic!("{name}"),
|
||||
}
|
||||
.map(Brep::made)
|
||||
.unwrap();
|
||||
let flat = cpp.export().unwrap();
|
||||
let ours = to_arrays(&case(name).body).unwrap();
|
||||
for id in 1..=WIRE_EDGE_NURBS_FORM {
|
||||
assert_eq!(ours.ints.get(&id).unwrap(), &flat.get_i64(id).unwrap(), "{name}: int array {id}");
|
||||
}
|
||||
for id in TOLERANCE..=VERTEX_TOLERANCE {
|
||||
let (a, b) = (ours.reals.get(&id).unwrap(), flat.get_f64(id).unwrap());
|
||||
assert_eq!(a.len(), b.len(), "{name}: real array {id} length");
|
||||
for (i, (x, y)) in a.iter().zip(&b).enumerate() {
|
||||
assert!((x - y).abs() <= 1e-12 * x.abs().max(1.0), "{name}: real array {id}[{i}]: {x} vs {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The record of solidcore's arrays, built in the C++ kernel, measures the analytic volume.
|
||||
#[cfg(feature = "native")]
|
||||
fn record_measures_in_cpp(name: &str) {
|
||||
let c = case(name);
|
||||
let rec = record(&to_arrays(&c.body).unwrap());
|
||||
let v = crate::Brep::from_record(&rec, true).unwrap().volume(1e-8).unwrap();
|
||||
assert!(rel(v, c.volume) < 1e-9, "{name}: {v} vs {}", c.volume);
|
||||
}
|
||||
|
||||
macro_rules! primitive {
|
||||
($m:ident, $name:literal $(, volume: $vol:meta)? $(, round_trip: $rt:meta)?) => {
|
||||
mod $m {
|
||||
#[test]
|
||||
fn exports_a_valid_record() {
|
||||
super::exports($name);
|
||||
}
|
||||
#[test]
|
||||
$(#[$vol])?
|
||||
fn measures_its_volume() {
|
||||
super::measures($name);
|
||||
}
|
||||
#[test]
|
||||
$(#[$rt])?
|
||||
fn round_trips_through_the_record() {
|
||||
super::round_trips($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn its_record_measures_in_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::record_measures_in_cpp($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn arrays_match_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::matches_cpp($name);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
primitive!(box_, "box");
|
||||
primitive!(cylinder, "cylinder");
|
||||
primitive!(sphere, "sphere");
|
||||
primitive!(cone, "cone");
|
||||
primitive!(frustum, "frustum");
|
||||
primitive!(torus, "torus");
|
||||
|
||||
#[test]
|
||||
fn hostile_arrays_are_refused() {
|
||||
let c = case("box");
|
||||
let good = to_arrays(&c.body).unwrap();
|
||||
assert!(from_arrays(&Arrays::default()).is_err());
|
||||
for id in good.ints.keys().copied().collect::<Vec<_>>() {
|
||||
let mut a = good.clone();
|
||||
if let Some(v) = a.ints.get_mut(&id) {
|
||||
v.push(7);
|
||||
}
|
||||
let _ = from_arrays(&a);
|
||||
let mut a = good.clone();
|
||||
if let Some(v) = a.ints.get_mut(&id).and_then(|v| v.first_mut()) {
|
||||
*v = i64::MAX;
|
||||
}
|
||||
let _ = from_arrays(&a);
|
||||
}
|
||||
for id in good.reals.keys().copied().collect::<Vec<_>>() {
|
||||
let mut a = good.clone();
|
||||
if let Some(v) = a.reals.get_mut(&id).and_then(|v| v.first_mut()) {
|
||||
*v = f64::NAN;
|
||||
assert!(from_arrays(&a).is_err(), "NaN in {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetOrCreateGwNurbPointer` on a parabola: the one with its NURB gives it; one built over an
|
||||
/// interval too small for two end tangents to cross has none and makes none, an error naming
|
||||
/// `MakeNurb`.
|
||||
#[test]
|
||||
fn a_parabola_gives_its_nurb_or_says_it_cannot() {
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::curve::parabola::Parabola;
|
||||
use solidcore::geom::extent1d::Extent1d;
|
||||
use solidcore::geom::vector3d::{Point3, Vec3};
|
||||
let (x, y) = (Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
|
||||
let part = |lo: f64, hi: f64| -> crate::Result<bool> {
|
||||
let c = Curve::from(Parabola::new(Point3::new(0.0, 0.0, 0.0), x, y, Extent1d::from_min_max(lo, hi), 1.0, 3));
|
||||
let k = solidcore::kernel::Kernel::default();
|
||||
Ok(super::convert::bspline_curve_part(&k, &c)?.is_some_and(|b| b.gw_nurb().is_some()))
|
||||
};
|
||||
assert_eq!(part(-1.0, 1.0), Ok(true));
|
||||
let none = part(0.5, 0.5);
|
||||
assert!(matches!(&none, Err(e) if e.to_string().contains("MakeNurb")), "{none:?}");
|
||||
}
|
||||
|
||||
/// `Validate` against the C++ one: each array of each body grown, emptied and given hostile values.
|
||||
/// What ours refuses the C++ kernel does not build, and what the C++ kernel refuses with
|
||||
/// `SM_ERR_INVALID_INPUT`, its `Validate`'s answer (its build can answer it too), ours does not build.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn validate_refuses_what_the_cpp_kernel_refuses() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
/// `SM_ERR_INVALID_INPUT`.
|
||||
const INVALID_INPUT: i32 = 1007;
|
||||
use crate::flat::Flat;
|
||||
use crate::{Brep, Error, ffi};
|
||||
crate::init().unwrap();
|
||||
let plate = ffi::make_box(&[0.0; 3], &[40.0, 30.0, 5.0]).unwrap();
|
||||
let rod = ffi::make_cylinder(&[20.0, 15.0, -1.0], 6.0, 7.0).unwrap();
|
||||
let cone = ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 2.0, 9.0).unwrap();
|
||||
let bodies = [
|
||||
("holed plate", Brep::made(ffi::ops::boolean(&plate, &rod, ffi::ops::BOOLEAN_SUBTRACT).unwrap())),
|
||||
("frustum", Brep::made(cone)),
|
||||
("sphere", Brep::made(ffi::make_sphere(&[1.0, 2.0, 3.0], 7.0).unwrap())),
|
||||
("torus", Brep::made(ffi::make_torus(&[1.0, -1.0, 0.5], 10.0, 3.0).unwrap())),
|
||||
];
|
||||
let (mut compared, mut refusals) = (0usize, 0usize);
|
||||
for (name, body) in &bodies {
|
||||
let good = body.cpp_arrays().unwrap();
|
||||
let mut hostile: Vec<(String, Arrays)> = Vec::new();
|
||||
for (id, v) in &good.ints {
|
||||
let mut grown = good.clone();
|
||||
grown.ints.get_mut(id).unwrap().push(1);
|
||||
hostile.push((format!("int {id} grown"), grown));
|
||||
let mut none = good.clone();
|
||||
none.ints.get_mut(id).unwrap().clear();
|
||||
hostile.push((format!("int {id} emptied"), none));
|
||||
for at in [0, v.len().saturating_sub(1)] {
|
||||
for x in [-1, 0, 1, 2, 7, 1_000_000, i64::MAX, i64::MIN] {
|
||||
if v.get(at).is_some_and(|y| *y != x) {
|
||||
let mut a = good.clone();
|
||||
a.ints.get_mut(id).unwrap()[at] = x;
|
||||
hostile.push((format!("int {id}[{at}] = {x}"), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, v) in &good.reals {
|
||||
let mut grown = good.clone();
|
||||
grown.reals.get_mut(id).unwrap().push(1.0);
|
||||
hostile.push((format!("real {id} grown"), grown));
|
||||
let mut none = good.clone();
|
||||
none.reals.get_mut(id).unwrap().clear();
|
||||
hostile.push((format!("real {id} emptied"), none));
|
||||
for at in [0, v.len().saturating_sub(1)] {
|
||||
for x in [0.0, -1.0, 2.0, 1e10, -1e-13, f64::NAN, f64::INFINITY] {
|
||||
if !v.is_empty() {
|
||||
let mut a = good.clone();
|
||||
a.reals.get_mut(id).unwrap()[at] = x;
|
||||
hostile.push((format!("real {id}[{at}] = {x}"), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (what, a) in hostile {
|
||||
let cpp = Flat::from_arrays(&a).unwrap().import().map(|_| ());
|
||||
let cpp_refused = cpp == Err(Error::Status(INVALID_INPUT));
|
||||
let ours = super::convert::validate_refuses(&a);
|
||||
compared += 1;
|
||||
refusals += usize::from(ours);
|
||||
assert!(!(ours && cpp.is_ok()), "{name}, {what}: refused here, built in C++");
|
||||
assert!(!(cpp_refused && !ours && from_arrays(&a).is_ok()), "{name}, {what}: refused in C++, built here");
|
||||
}
|
||||
}
|
||||
assert!(compared > 500 && refusals > 200, "{compared} compared, {refusals} refused");
|
||||
}
|
||||
|
||||
/// The C++ kernel's own box arrays (what `Brep::core` converts) build in solidcore.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn a_cpp_box_comes_into_solidcore() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::init().unwrap();
|
||||
let cpp = crate::ffi::make_box(&[1.0, -2.0, 3.0], &[10.0, 20.0, 30.0]).map(crate::Brep::made).unwrap();
|
||||
let body = from_arrays(&cpp.cpp_arrays().unwrap()).unwrap();
|
||||
let v = volume(&body, 1e-8).unwrap();
|
||||
assert!(rel(v, 6000.0) < 1e-9, "{v}");
|
||||
record(&to_arrays(&body).unwrap());
|
||||
}
|
||||
|
||||
/// Each edge's and vertex's tolerance crosses the kernels as it is. One box edge given eight times
|
||||
/// the box's tolerance, in arrays whose TOLERANCE is the largest (as the C++ kernel writes them),
|
||||
/// keeps it round after round, and the B-rep and every other edge and vertex keep the box's. Every
|
||||
/// crossing used to give every entity the largest, so a chain of blends on a mixed route grew the
|
||||
/// whole body's tolerance fourfold per blend (fillets/2341).
|
||||
#[test]
|
||||
fn tolerances_cross_the_arrays_per_entity() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::flat::{EDGE_TOLERANCE, TOLERANCE, VERTEX_TOLERANCE};
|
||||
let mut a = to_arrays(&case("box").body).unwrap();
|
||||
let base = a.reals[&TOLERANCE][0];
|
||||
assert!(a.reals[&EDGE_TOLERANCE].iter().chain(&a.reals[&VERTEX_TOLERANCE]).all(|&t| t == base), "{a:?}");
|
||||
a.reals.get_mut(&EDGE_TOLERANCE).unwrap()[0] = 8.0 * base;
|
||||
a.reals.get_mut(&TOLERANCE).unwrap()[0] = 8.0 * base;
|
||||
for round in 0..3 {
|
||||
let body = from_arrays(&a).unwrap();
|
||||
assert_eq!(body.0.lock().unwrap().tolerance(), base / 2.0, "round {round}");
|
||||
a = to_arrays(&body).unwrap();
|
||||
assert_eq!(a.reals[&TOLERANCE], [base], "round {round}");
|
||||
let edges = &a.reals[&EDGE_TOLERANCE];
|
||||
assert_eq!(edges[0], 8.0 * base, "round {round}");
|
||||
assert!(edges[1..].iter().chain(&a.reals[&VERTEX_TOLERANCE]).all(|&t| t == base), "round {round}: {a:?}");
|
||||
// and through the C++ kernel, which keeps them too
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
let cpp = crate::flat::Flat::from_arrays(&a).unwrap().import().unwrap().cpp_arrays().unwrap();
|
||||
assert_eq!((&cpp.reals[&EDGE_TOLERANCE], &cpp.reals[&VERTEX_TOLERANCE]), (edges, &a.reals[&VERTEX_TOLERANCE]), "round {round}");
|
||||
a = cpp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A 20 mm box minus a centred 10 mm box: a sealed void, so its solid region has two shells.
|
||||
fn sealed_void() -> Body {
|
||||
boolean(&make_box([-10.0; 3], [20.0; 3]).unwrap(), &make_box([-5.0; 3], [10.0; 3]).unwrap(), BooleanOp::Subtract).unwrap().unwrap()
|
||||
}
|
||||
|
||||
/// 20³ − 10³.
|
||||
const SEALED_VOID_VOLUME: f64 = 8000.0 - 1000.0;
|
||||
|
||||
/// Each bounded region's shells, as each shell's reach: the largest |coordinate| of its vertices
|
||||
/// (10 for the 20 mm box, 5 for the 10 mm one).
|
||||
fn reaches(rec: &b::Brep) -> Vec<Vec<f64>> {
|
||||
let reach = |s: u32| {
|
||||
let Some(b::Shell::Faces { faces, .. }) = rec.shells.get(s as usize) else { panic!("shell {s}") };
|
||||
let mut r = 0.0f64;
|
||||
for f in faces {
|
||||
for l in &rec.faces[f.face as usize].loops {
|
||||
let b::Loop::Edges { edges } = l else { continue };
|
||||
for e in edges {
|
||||
for &v in &rec.edges[e.edge as usize].vertices {
|
||||
r = rec.vertices[v as usize].point.iter().fold(r, |r, c| r.max(c.abs()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r
|
||||
};
|
||||
rec.regions.iter().skip(1).map(|r| r.shells.iter().map(|&s| reach(s)).collect()).collect()
|
||||
}
|
||||
|
||||
/// The sealed void's record: the solid region lists the outer box first, then the void's box;
|
||||
/// the void region its box alone.
|
||||
fn outer_first(a: &Arrays, at: &str) {
|
||||
let mut got = reaches(&record(a));
|
||||
got.sort_by_key(|x| std::cmp::Reverse(x.len()));
|
||||
assert_eq!(got, [vec![10.0, 5.0], vec![5.0]], "{at}");
|
||||
}
|
||||
|
||||
/// The arrays with every region's shells listed in reverse: each shell's block (its counts and
|
||||
/// its face uses) moved, nothing else touched.
|
||||
fn shells_reversed(a: &Arrays) -> Arrays {
|
||||
use crate::flat::{FACEUSE_FACE, FACEUSE_ORIENTATION, REGION_SHELL_COUNT, SHELL_FACEUSE_COUNT, SHELL_POINT_TYPE, SHELL_WIRE_EDGE_COUNT};
|
||||
let mut p = a.clone();
|
||||
let blocks = [SHELL_FACEUSE_COUNT, SHELL_WIRE_EDGE_COUNT, SHELL_POINT_TYPE, FACEUSE_FACE, FACEUSE_ORIENTATION];
|
||||
for id in blocks {
|
||||
p.ints.get_mut(&id).unwrap().clear();
|
||||
}
|
||||
let (mut shell, mut fu) = (0usize, 0usize);
|
||||
for &n in &a.ints[®ION_SHELL_COUNT] {
|
||||
let mut region = Vec::new();
|
||||
for _ in 0..n {
|
||||
let uses = usize::try_from(a.ints[&SHELL_FACEUSE_COUNT][shell]).unwrap();
|
||||
region.push((shell, fu..fu + uses));
|
||||
shell += 1;
|
||||
fu += uses;
|
||||
}
|
||||
for (s, uses) in region.into_iter().rev() {
|
||||
for id in [SHELL_FACEUSE_COUNT, SHELL_WIRE_EDGE_COUNT, SHELL_POINT_TYPE] {
|
||||
let v = a.ints[&id][s];
|
||||
p.ints.get_mut(&id).unwrap().push(v);
|
||||
}
|
||||
for id in [FACEUSE_FACE, FACEUSE_ORIENTATION] {
|
||||
p.ints.get_mut(&id).unwrap().extend_from_slice(&a.ints[&id][uses.clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_ne!(p.ints, a.ints, "nothing to reverse");
|
||||
p
|
||||
}
|
||||
|
||||
/// solidcore's sealed void exports with its outer shell first, comes back through the arrays
|
||||
/// with it first, and keeps its volume.
|
||||
#[test]
|
||||
fn a_sealed_void_crosses_the_arrays_outer_shell_first() {
|
||||
let body = sealed_void();
|
||||
let v = volume(&body, 1e-9).unwrap();
|
||||
assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "{v}");
|
||||
let a = to_arrays(&body).unwrap();
|
||||
outer_first(&a, "export");
|
||||
let back = from_arrays(&import::arrays(&record(&a), true).unwrap()).unwrap();
|
||||
outer_first(&to_arrays(&back).unwrap(), "round trip");
|
||||
let v = volume(&back, 1e-9).unwrap();
|
||||
assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "round trip: {v}");
|
||||
}
|
||||
|
||||
/// Arrays listing the solid region's void shell before the box around it import with the box
|
||||
/// first (SMLib takes a region's first shell as its enclosing one), the volume unchanged.
|
||||
#[test]
|
||||
fn shells_listed_inner_first_import_outer_first() {
|
||||
let a = shells_reversed(&to_arrays(&sealed_void()).unwrap());
|
||||
let back = from_arrays(&a).unwrap();
|
||||
outer_first(&to_arrays(&back).unwrap(), "inner first");
|
||||
let v = volume(&back, 1e-9).unwrap();
|
||||
assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "inner first: {v}");
|
||||
}
|
||||
|
||||
/// The C++ kernel's sealed void: solidcore imports its arrays with the outer shell first, in
|
||||
/// either shell order, at the C++ kernel's volume, and the C++ kernel reads solidcore's arrays
|
||||
/// (either order) back as a body that passes its checks at that volume.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn a_sealed_void_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
let make = |o: f64, s: f64| ffi::make_box(&[o; 3], &[s; 3]).map(Brep::made).unwrap();
|
||||
let (outer, inner) = (make(-10.0, 20.0), make(-5.0, 10.0));
|
||||
let cpp = Brep::made(ffi::ops::boolean(outer.cpp().unwrap(), inner.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap());
|
||||
let theirs = ffi::volume(cpp.cpp().unwrap(), 1e-9).unwrap();
|
||||
assert!(rel(theirs, SEALED_VOID_VOLUME) < 1e-9, "C++ {theirs}");
|
||||
let arrays = cpp.cpp_arrays().unwrap();
|
||||
outer_first(&arrays, "C++ export");
|
||||
for (at, a) in [("C++ arrays", arrays.clone()), ("C++ arrays, inner first", shells_reversed(&arrays))] {
|
||||
let body = from_arrays(&a).unwrap();
|
||||
outer_first(&to_arrays(&body).unwrap(), at);
|
||||
let v = volume(&body, 1e-9).unwrap();
|
||||
assert!(rel(v, theirs) < 1e-9, "{at}: solidcore {v} vs C++ {theirs}");
|
||||
}
|
||||
let ours = to_arrays(&sealed_void()).unwrap();
|
||||
for (at, a) in [("solidcore arrays", ours.clone()), ("solidcore arrays, inner first", shells_reversed(&ours))] {
|
||||
let h = crate::flat::Flat::from_arrays(&a).unwrap().import().unwrap();
|
||||
let v = ffi::volume(h.cpp().unwrap(), 1e-9).unwrap();
|
||||
assert!(rel(v, theirs) < 1e-9, "{at}: C++ {v} vs {theirs}");
|
||||
let mut buf = vec![0u8; 1 << 16];
|
||||
let (len, failures) = ffi::check::check(h.cpp().unwrap(), 2, &mut buf).unwrap();
|
||||
assert_eq!(failures, 0, "{at}: {}", String::from_utf8_lossy(&buf[..len]));
|
||||
outer_first(&h.cpp_arrays().unwrap(), &format!("{at}, C++ export"));
|
||||
}
|
||||
}
|
||||
|
||||
/// C++ bodies through solidcore and back. Three crossings of the same C++ arrays are held equal,
|
||||
/// ints exactly and reals to 1e-12 (the record too, where the record can say it):
|
||||
/// - the C++ kernel's own: its import of them, exported again;
|
||||
/// - solidcore's: `from_arrays`, then `to_arrays`;
|
||||
/// - both: solidcore's arrays imported by the C++ kernel and exported again.
|
||||
///
|
||||
/// The first is the reference, not the arrays that went in: a crossing itself moves a few reals on
|
||||
/// the C++ kernel too (UV trims made at export, about 1e-7 on a shelled box), so those are held to
|
||||
/// the arrays that went in only at [`FIRST_CROSSING`]. Tolerances are excepted: they cross on
|
||||
/// purpose as each entity's own (`own_tolerances`, TOLERANCE as twice the B-rep's) where the C++
|
||||
/// export writes the largest over them all, so that a body does not grow its tolerance at every
|
||||
/// crossing (`tolerances_cross_the_arrays_per_entity`); on a body an operation made the numbers
|
||||
/// cannot agree. So is EXTENT, the bounding box padded by those tolerances.
|
||||
#[cfg(feature = "native")]
|
||||
mod cpp_round_trip {
|
||||
use serde_json::Value;
|
||||
use solidcraft_geom::brep as b;
|
||||
|
||||
use super::{close, from_arrays, native, rel, to_arrays, volume};
|
||||
use crate::flat::{self, EDGE_TOLERANCE, EXTENT, Flat, TOLERANCE, VERTEX_TOLERANCE};
|
||||
use crate::import::Arrays;
|
||||
use crate::sweeps::{Curve, DraftCorner, SweepFrame};
|
||||
use crate::{Brep, ffi};
|
||||
|
||||
const PICK_TOL: f64 = 1e-6;
|
||||
|
||||
/// How far any crossing, the C++ kernel's own included, may move a real of the arrays that
|
||||
/// went in.
|
||||
const FIRST_CROSSING: f64 = 1e-6;
|
||||
|
||||
fn cpp_box(o: [f64; 3], s: [f64; 3]) -> Brep {
|
||||
crate::init().unwrap();
|
||||
Brep::made(ffi::make_box(&o, &s).unwrap())
|
||||
}
|
||||
|
||||
fn cpp_cylinder(o: [f64; 3], r: f64, h: f64) -> Brep {
|
||||
crate::init().unwrap();
|
||||
Brep::made(ffi::make_cylinder(&o, r, h).unwrap())
|
||||
}
|
||||
|
||||
fn cut(a: &Brep, b: &Brep) -> Brep {
|
||||
Brep::made(ffi::ops::boolean(a.cpp().unwrap(), b.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap())
|
||||
}
|
||||
|
||||
/// `f` with the sweeps on the C++ kernel, whatever the choice.
|
||||
fn on_cpp(f: impl FnOnce() -> crate::Result<Brep>) -> Brep {
|
||||
crate::init().unwrap();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let r = f();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
/// The record as JSON, every `tolerance` (the B-rep's, an edge's, a vertex's) gone.
|
||||
fn without_tolerances(rec: &b::Brep) -> Value {
|
||||
fn strip(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(m) => {
|
||||
m.remove("tolerance");
|
||||
m.values_mut().for_each(strip);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(strip),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut v = serde_json::to_value(rec).unwrap();
|
||||
strip(&mut v);
|
||||
v
|
||||
}
|
||||
|
||||
/// `want` and `got` agree: ints exact, reals within `tol`, tolerances and EXTENT excepted.
|
||||
fn same_arrays(at: &str, want: &Arrays, got: &Arrays, tol: f64) {
|
||||
assert_eq!(want.ints.keys().collect::<Vec<_>>(), got.ints.keys().collect::<Vec<_>>(), "{at}: int arrays");
|
||||
for (id, v) in &want.ints {
|
||||
assert_eq!(v, &got.ints[id], "{at}: int array {id}");
|
||||
}
|
||||
assert_eq!(want.reals.keys().collect::<Vec<_>>(), got.reals.keys().collect::<Vec<_>>(), "{at}: real arrays");
|
||||
for (id, v) in &want.reals {
|
||||
if [TOLERANCE, EXTENT, EDGE_TOLERANCE, VERTEX_TOLERANCE].contains(id) {
|
||||
assert_eq!(v.len(), got.reals[id].len(), "{at}: real array {id} length");
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (serde_json::to_value(v).unwrap(), serde_json::to_value(&got.reals[id]).unwrap());
|
||||
close(&x, &y, tol, &format!("{at}: real array {id}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`same_arrays`], and their records the same, valid and with tolerances excepted.
|
||||
fn same_records(at: &str, want: &Arrays, got: &Arrays, tol: f64) -> b::Brep {
|
||||
same_arrays(at, want, got, tol);
|
||||
let ((w, _), (g, _)) = (native(want).unwrap(), native(got).unwrap());
|
||||
w.validate().unwrap();
|
||||
g.validate().unwrap();
|
||||
close(&without_tolerances(&w), &without_tolerances(&g), tol, at);
|
||||
w
|
||||
}
|
||||
|
||||
/// The three crossings of `theirs`: the C++ kernel's own, solidcore's, and solidcore's arrays
|
||||
/// back through the C++ kernel.
|
||||
fn crossings(theirs: &Arrays) -> [Arrays; 3] {
|
||||
let own = Flat::from_arrays(theirs).unwrap().import().unwrap().cpp_arrays().unwrap();
|
||||
let ours = to_arrays(&from_arrays(theirs).unwrap()).unwrap();
|
||||
let back = Flat::from_arrays(&ours).unwrap().import().unwrap().cpp_arrays().unwrap();
|
||||
[own, ours, back]
|
||||
}
|
||||
|
||||
/// Arrays the record cannot say (wire and point shells): the crossings' arrays only.
|
||||
fn crosses_as_arrays(name: &str, theirs: &Arrays) {
|
||||
let [own, ours, back] = crossings(theirs);
|
||||
same_arrays(&format!("{name}, solidcore's arrays"), &own, &ours, 1e-12);
|
||||
same_arrays(&format!("{name}, back through C++"), &own, &back, 1e-12);
|
||||
}
|
||||
|
||||
/// `cpp` across: returns its record, for the case to say what it holds.
|
||||
fn crosses(name: &str, cpp: &Brep) -> b::Brep {
|
||||
let theirs = cpp.cpp_arrays().unwrap();
|
||||
let [own, ours, back] = crossings(&theirs);
|
||||
same_records(&format!("{name}, solidcore's arrays"), &own, &ours, 1e-12);
|
||||
same_records(&format!("{name}, back through C++"), &own, &back, 1e-12);
|
||||
same_records(&format!("{name}, the arrays that went in"), &theirs, &ours, FIRST_CROSSING)
|
||||
}
|
||||
|
||||
/// [`crosses`], and solidcore measures the C++ kernel's volume.
|
||||
fn crosses_with_volume(name: &str, cpp: &Brep) -> b::Brep {
|
||||
let rec = crosses(name, cpp);
|
||||
let body = from_arrays(&cpp.cpp_arrays().unwrap()).unwrap();
|
||||
let (v, want) = (volume(&body, 1e-8).unwrap(), ffi::volume(cpp.cpp().unwrap(), 1e-8).unwrap());
|
||||
assert!(rel(v, want) < 1e-7, "{name}: solidcore {v} vs C++ {want}");
|
||||
rec
|
||||
}
|
||||
|
||||
fn has_face(rec: &b::Brep, f: impl Fn(&b::Face) -> bool) -> bool {
|
||||
rec.faces.iter().any(f)
|
||||
}
|
||||
|
||||
/// A 20 mm box with three edges at one corner rounded: rolling-ball faces and a corner patch,
|
||||
/// on an operation's tolerances.
|
||||
#[test]
|
||||
fn a_filleted_box() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = cpp_box([0.0; 3], [20.0; 3]);
|
||||
let edges = [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]];
|
||||
let f = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::Fillet, a.cpp().unwrap(), &edges, PICK_TOL, 2.0).unwrap());
|
||||
let rec = crosses_with_volume("filleted box", &f);
|
||||
assert!(rec.faces.len() > 6, "{}", rec.faces.len());
|
||||
}
|
||||
|
||||
/// A plate with a round hole: inner loops, and a cylinder face looking at its axis.
|
||||
#[test]
|
||||
fn a_holed_plate() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let p = cut(&cpp_box([0.0; 3], [40.0, 30.0, 5.0]), &cpp_cylinder([20.0, 15.0, -1.0], 6.0, 7.0));
|
||||
let rec = crosses_with_volume("holed plate", &p);
|
||||
assert!(has_face(&rec, |f| f.loops.len() > 1), "no inner loop");
|
||||
}
|
||||
|
||||
/// A square swept along a cubic B-spline path: B-spline walls that are nothing analytic.
|
||||
#[test]
|
||||
fn a_swept_nurbs_face() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let c = [[-2.0, -2.0, 0.0], [2.0, -2.0, 0.0], [2.0, 2.0, 0.0], [-2.0, 2.0, 0.0]];
|
||||
let square: Vec<Curve> = (0..4).map(|i| Curve::Line { start: c[i], end: c[(i + 1) % 4] }).collect();
|
||||
let points = [[0.0, 0.0, 0.0, 1.0], [0.0, 10.0, 20.0, 1.0], [10.0, -10.0, 40.0, 1.0], [10.0, 0.0, 60.0, 1.0]];
|
||||
let path = [Curve::Nurbs { degree: 3, points: points.to_vec(), knots: vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0] }];
|
||||
let s = on_cpp(|| Brep::sweep(&square, &path, SweepFrame::Follow, true));
|
||||
let rec = crosses_with_volume("swept square", &s);
|
||||
assert!(has_face(&rec, |f| matches!(f.surface, b::Surface::Spline { .. } | b::Surface::RationalSpline { .. })), "no NURBS face");
|
||||
}
|
||||
|
||||
/// A cylinder sliced by a plane leaning 45 degrees: the cut's edge an ellipse.
|
||||
#[test]
|
||||
fn an_ellipse_edge() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let cyl = cpp_cylinder([0.0, 0.0, -30.0], 5.0, 60.0);
|
||||
let (s, c) = std::f64::consts::FRAC_PI_4.sin_cos();
|
||||
// About y through the origin; the box's underside is the plane z = 0 turned.
|
||||
let turn = [[c, 0.0, s, 0.0], [0.0, 1.0, 0.0, 0.0], [-s, 0.0, c, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
let tool = Brep::made(ffi::transform(cpp_box([-50.0, -50.0, 0.0], [100.0; 3]).cpp().unwrap(), &turn).unwrap());
|
||||
let rec = crosses_with_volume("leaning cut", &cut(&cyl, &tool));
|
||||
assert!(rec.edges.iter().any(|e| matches!(e.curve, b::Curve::Ellipse { .. })), "no ellipse edge");
|
||||
}
|
||||
|
||||
/// A 20 mm box with a 10 mm void sealed inside: two shells in the solid's region.
|
||||
#[test]
|
||||
fn a_sealed_void() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let v = cut(&cpp_box([-10.0; 3], [20.0; 3]), &cpp_box([-5.0; 3], [10.0; 3]));
|
||||
let rec = crosses_with_volume("sealed void", &v);
|
||||
assert!(rec.regions.iter().any(|r| r.shells.len() == 2), "{:?}", rec.regions);
|
||||
assert!(rec.regions.iter().any(|r| r.void), "{:?}", rec.regions);
|
||||
}
|
||||
|
||||
/// One planar face, and an open extrusion: a sheet's shells, no solid.
|
||||
#[test]
|
||||
fn sheets() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let c = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 6.0, 0.0], [0.0, 6.0, 0.0]];
|
||||
let rect: Vec<Curve> = (0..4).map(|i| Curve::Line { start: c[i], end: c[(i + 1) % 4] }).collect();
|
||||
let rec = crosses("planar sheet", &on_cpp(|| Brep::planar_faces(&rect)));
|
||||
assert_eq!(rec.faces.len(), 1);
|
||||
let open = on_cpp(|| Brep::extrude(&rect, [0.0, 0.0, 1.0], 5.0, 0.0, DraftCorner::default(), false));
|
||||
let rec = crosses("open extrusion", &open);
|
||||
assert_eq!(rec.faces.len(), 4);
|
||||
}
|
||||
|
||||
/// A C++ box's arrays with one wire edge of each kind (a line, a whole circle, a half ellipse,
|
||||
/// a cubic B-spline) and one point, each a shell of its own in the infinite region. The C API
|
||||
/// gives no body without a face (`take`), so these lie beside a box.
|
||||
fn box_with_wires_and_a_point() -> Arrays {
|
||||
let tau = std::f64::consts::TAU;
|
||||
let mut a = cpp_box([20.0; 3], [5.0; 3]).cpp_arrays().unwrap();
|
||||
let first_new = a.ints[&flat::VERTEX_POINT_TYPE].len() as i64;
|
||||
a.ints.get_mut(&flat::REGION_SHELL_COUNT).unwrap()[0] += 5;
|
||||
for (id, v) in
|
||||
[(flat::SHELL_FACEUSE_COUNT, [0, 0, 0, 0, 0]), (flat::SHELL_WIRE_EDGE_COUNT, [1, 1, 1, 1, 0]), (flat::SHELL_POINT_TYPE, [0, 0, 0, 0, 1])]
|
||||
{
|
||||
a.ints.get_mut(&id).unwrap().splice(1..1, v);
|
||||
}
|
||||
a.ints.insert(
|
||||
flat::WIRE_EDGE_CURVE_TYPE,
|
||||
[flat::CURVE_LINE, flat::CURVE_CIRCLE, flat::CURVE_ELLIPSE, flat::CURVE_NURBS].map(i64::from).to_vec(),
|
||||
);
|
||||
a.ints.insert(flat::WIRE_EDGE_VERTICES, [0, 1, 2, 2, 3, 4, 5, 6].map(|v| first_new + v).to_vec());
|
||||
a.ints.insert(flat::WIRE_EDGE_NURBS_VERTEX_COUNT, vec![4]);
|
||||
a.ints.insert(flat::WIRE_EDGE_NURBS_ORDER, vec![4]);
|
||||
let points = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [4.0, 0.0, 5.0], [3.0, 0.0, -5.0], [-3.0, 0.0, -5.0], [0.0, 9.0, 0.0], [9.0, 9.0, 3.0]];
|
||||
a.ints.get_mut(&flat::VERTEX_POINT_TYPE).unwrap().extend([1; 7]);
|
||||
a.reals.get_mut(&flat::VERTEX_POSITION).unwrap().extend(points.concat());
|
||||
let base = a.reals[&TOLERANCE][0];
|
||||
a.reals.get_mut(&VERTEX_TOLERANCE).unwrap().extend([base; 7]);
|
||||
let reals: [(i32, Vec<f64>); 17] = [
|
||||
(flat::SHELL_POINT_POSITION, vec![1.0, 2.0, 3.0]),
|
||||
(flat::WIRE_EDGE_RANGE, vec![0.0, 10.0, 0.0, tau, 0.0, std::f64::consts::PI, 0.0, 1.0]),
|
||||
(flat::WIRE_EDGE_LINE_ORIGIN, vec![0.0; 3]),
|
||||
(flat::WIRE_EDGE_LINE_DIRECTION, vec![1.0, 0.0, 0.0]),
|
||||
(flat::WIRE_EDGE_CIRCLE_CENTER, vec![0.0, 0.0, 5.0]),
|
||||
(flat::WIRE_EDGE_CIRCLE_AXIS, vec![0.0, 0.0, 1.0]),
|
||||
(flat::WIRE_EDGE_CIRCLE_REF_DIRECTION, vec![1.0, 0.0, 0.0]),
|
||||
(flat::WIRE_EDGE_CIRCLE_RADIUS, vec![4.0]),
|
||||
(flat::WIRE_EDGE_ELLIPSE_CENTER, vec![0.0, 0.0, -5.0]),
|
||||
(flat::WIRE_EDGE_ELLIPSE_AXIS, vec![0.0, 0.0, 1.0]),
|
||||
(flat::WIRE_EDGE_ELLIPSE_REF_DIRECTION, vec![1.0, 0.0, 0.0]),
|
||||
(flat::WIRE_EDGE_ELLIPSE_X_RADIUS, vec![3.0]),
|
||||
(flat::WIRE_EDGE_ELLIPSE_Y_RADIUS, vec![2.0]),
|
||||
(flat::WIRE_EDGE_NURBS_CVS, [points[5], [3.0, 12.0, 0.0], [6.0, 6.0, 2.0], points[6]].concat()),
|
||||
(flat::WIRE_EDGE_NURBS_KNOTS, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]),
|
||||
(flat::WIRE_EDGE_NURBS_WEIGHTS, Vec::new()),
|
||||
(EXTENT, vec![-3.0, 0.0, -5.0, 25.0, 25.0, 25.0]),
|
||||
];
|
||||
a.reals.extend(reals);
|
||||
a
|
||||
}
|
||||
|
||||
/// Wire edges no face uses and a point shell. The record says no such shell from SMLib, so
|
||||
/// arrays only.
|
||||
#[test]
|
||||
fn wire_edges_and_a_point_shell() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let theirs = Flat::from_arrays(&box_with_wires_and_a_point()).unwrap().import().unwrap().cpp_arrays().unwrap();
|
||||
assert_eq!(theirs.ints[&flat::WIRE_EDGE_CURVE_TYPE].len(), 4);
|
||||
assert_eq!(theirs.reals[&flat::SHELL_POINT_POSITION], [1.0, 2.0, 3.0]);
|
||||
crosses_as_arrays("wire edges and a point", &theirs);
|
||||
}
|
||||
|
||||
/// Two boxes apart, united: two lumps.
|
||||
#[test]
|
||||
fn a_multi_lump_body() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (a, b) = (cpp_box([0.0; 3], [10.0; 3]), cpp_box([30.0, 0.0, 0.0], [5.0; 3]));
|
||||
let u = Brep::made(ffi::ops::boolean(a.cpp().unwrap(), b.cpp().unwrap(), ffi::ops::BOOLEAN_UNION).unwrap());
|
||||
let rec = crosses_with_volume("two lumps", &u);
|
||||
assert_eq!(ffi::ops::solid_count(u.cpp().unwrap()).unwrap(), 2);
|
||||
assert_eq!(rec.faces.len(), 12);
|
||||
}
|
||||
|
||||
/// A rounded box shelled: faces on the offsets of its rounds, on the operation's tolerances.
|
||||
#[test]
|
||||
fn a_shelled_rounded_box() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = cpp_box([0.0; 3], [20.0; 3]);
|
||||
let r = ffi::ops::pick_op(ffi::ops::Pick::Fillet, a.cpp().unwrap(), &[[10.0, 0.0, 0.0]], PICK_TOL, 2.0).unwrap();
|
||||
let s = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::Shell, &r, &[[10.0, 10.0, 20.0]], PICK_TOL, -1.0).unwrap());
|
||||
crosses_with_volume("shelled rounded box", &s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! What a failing solidcore call leaves behind, and which one is running: `smlib_set_dump_dir` and
|
||||
//! `smlib_current_call` for this kernel. While a folder is set, each call's B-rep inputs are
|
||||
//! written to memory before it runs (it may edit them) and to files only if it fails:
|
||||
//! `<n>-<call>.txt`, what failed, and `<n>-<call>-<input>.smb`, each input as a binary `.smb`, which
|
||||
//! `Brep::read` reads back.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Mutex, PoisonError};
|
||||
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::topology::Brep;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
static DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
|
||||
static SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
/// The inputs of the call running now, in the order it took them.
|
||||
static INPUTS: Mutex<Vec<(&'static str, std::result::Result<Vec<u8>, String>)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Where failing calls leave their inputs; `None` stops it.
|
||||
pub(crate) fn set_dir(dir: Option<PathBuf>) {
|
||||
*DIR.lock().unwrap_or_else(PoisonError::into_inner) = dir;
|
||||
}
|
||||
|
||||
fn dumping() -> bool {
|
||||
DIR.lock().unwrap_or_else(PoisonError::into_inner).is_some()
|
||||
}
|
||||
|
||||
/// A new call, with no inputs yet.
|
||||
pub(super) fn begin() {
|
||||
if dumping() {
|
||||
INPUTS.lock().unwrap_or_else(PoisonError::into_inner).clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// `b` is an input of the running call, by the name `what`.
|
||||
pub(crate) fn input(k: &Kernel, what: &'static str, b: &Brep) {
|
||||
if dumping() {
|
||||
let bytes = super::file::bytes(k, b, false).map_err(|e| e.to_string());
|
||||
INPUTS.lock().unwrap_or_else(PoisonError::into_inner).push((what, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
fn safe(call: &str) -> String {
|
||||
call.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }).collect()
|
||||
}
|
||||
|
||||
/// The running call failed: its inputs go to the folder. Failing to write them fails nothing else.
|
||||
pub(super) fn failed(call: &str, why: &str) {
|
||||
let Some(dir) = DIR.lock().unwrap_or_else(PoisonError::into_inner).clone() else { return };
|
||||
let n = SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let stem = format!("{n:06}-{}", safe(call));
|
||||
let mut text = format!("{call} -> {why}\nnumeric arguments are not recorded\n");
|
||||
for (what, bytes) in INPUTS.lock().unwrap_or_else(PoisonError::into_inner).drain(..) {
|
||||
match bytes {
|
||||
Ok(b) => {
|
||||
let name = format!("{stem}-{what}.smb");
|
||||
if let Err(e) = std::fs::write(dir.join(&name), b) {
|
||||
log::warn!(target: "smlib", "{name}: {e}");
|
||||
}
|
||||
text.push_str(&format!("input {what}: {name}\n"));
|
||||
}
|
||||
Err(e) => text.push_str(&format!("input {what}: could not be written: {e}\n")),
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(dir.join(format!("{stem}.txt")), text) {
|
||||
log::warn!(target: "smlib", "{stem}.txt: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// How a call's value reads: whether it is a refusal, for the calls that answer with one.
|
||||
pub(crate) trait Answer {
|
||||
fn refusal(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Answer for Result<T> {
|
||||
fn refusal(&self) -> Option<String> {
|
||||
self.as_ref().err().map(Error::to_string)
|
||||
}
|
||||
}
|
||||
|
||||
/// The values that are no refusal.
|
||||
macro_rules! plain {
|
||||
($($t:ty),* $(,)?) => { $(impl Answer for $t {})* };
|
||||
}
|
||||
plain!(
|
||||
(),
|
||||
bool,
|
||||
f64,
|
||||
usize,
|
||||
[usize; 3],
|
||||
([f64; 3], [f64; 3]),
|
||||
Vec<u8>,
|
||||
Vec<[f64; 3]>,
|
||||
Brep,
|
||||
solidcore::geom::vector3d::Point3,
|
||||
crate::history::Raw,
|
||||
crate::MassProperties
|
||||
);
|
||||
|
||||
impl<T> Answer for Option<T> {}
|
||||
|
||||
/// The longest call name [`current`] gives.
|
||||
const NAME_MAX: usize = 96;
|
||||
static NAME: [AtomicU8; NAME_MAX] = [const { AtomicU8::new(0) }; NAME_MAX];
|
||||
static NAME_LEN: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// `call` is running now (`None`: none is). One at a time, under the kernel's lock.
|
||||
pub(super) fn set_current(call: Option<&str>) {
|
||||
NAME_LEN.store(0, Ordering::SeqCst);
|
||||
let Some(call) = call else { return };
|
||||
let bytes = call.as_bytes();
|
||||
let n = bytes.len().min(NAME_MAX);
|
||||
for (slot, b) in NAME.iter().zip(bytes) {
|
||||
slot.store(*b, Ordering::Relaxed);
|
||||
}
|
||||
NAME_LEN.store(n, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// The call running now. Takes no lock, for crash reports, so it may be cut short or mixed with the
|
||||
/// next call's name if read just as one starts.
|
||||
pub(crate) fn current() -> Option<String> {
|
||||
let n = NAME_LEN.load(Ordering::SeqCst);
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = NAME.iter().take(n).map(|b| b.load(Ordering::Relaxed)).collect();
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `.smb` files on solidcore (`smlib_brep_read`, `smlib_brep_write`): the B-rep's contents through
|
||||
//! `SmApiReadBrepFromFile` and `SmApiWriteBrepToFile`, the file itself read and written here (the
|
||||
//! library touches no files, so that it builds for the browser).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::topology::api_files::{read_brep_from_file, write_brep_to_file};
|
||||
use solidcore::topology::brep::p10::TopologyEnv;
|
||||
use solidcore::topology::brep_data_write::FromBrepEnv;
|
||||
use solidcore::topology::topology::NotifyRecords;
|
||||
use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId};
|
||||
|
||||
use super::convert::key_of;
|
||||
use super::{Body, run, with_body};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Where the keys of a file's curves and surfaces start, clear of [`key_of`]'s entity keys.
|
||||
const FILE_KEYS: u64 = 1 << 50;
|
||||
|
||||
fn io(what: &str, path: &Path, e: &std::io::Error) -> Error {
|
||||
Error::Solidcore(format!("{what} {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The body in the file at `path`, ASCII or binary. Its UV trims are checked, not rebuilt, as the
|
||||
/// C API reads them.
|
||||
pub(crate) fn read(path: &Path, ascii: bool) -> Result<Body> {
|
||||
let data = std::fs::read(path).map_err(|e| io("reading", path, &e))?;
|
||||
let b = run("smlib_brep_read", |k| {
|
||||
let mut attributes = AttributeStore::default();
|
||||
let mut records = NotifyRecords::new();
|
||||
let mut env = TopologyEnv { all_attributes: &[], attributes: &mut attributes, key_of: &key_of, records: &mut records, next_key: FILE_KEYS };
|
||||
let mut b = read_brep_from_file(k, Some(data), ascii, false, &mut env)?;
|
||||
b.refresh_cached_topology();
|
||||
Ok(b)
|
||||
})?;
|
||||
Ok(Body::new(b))
|
||||
}
|
||||
|
||||
/// `b` as the contents of a `.smb` file, ASCII or binary.
|
||||
pub(super) fn bytes(k: &Kernel, b: &Brep, ascii: bool) -> solidcore::Result<Vec<u8>> {
|
||||
let store = AttributeStore::default();
|
||||
let records = NotifyRecords::new();
|
||||
let env = FromBrepEnv {
|
||||
attributes: &store,
|
||||
key_of: &key_of,
|
||||
records: &records,
|
||||
curve_key: &|_: EdgeId| ObjectKey(0),
|
||||
uv_curve_key: &|_: EdgeuseId| ObjectKey(0),
|
||||
surface_key: &|_: FaceId| ObjectKey(0),
|
||||
};
|
||||
write_brep_to_file(k, Some(b), &env, ascii)
|
||||
}
|
||||
|
||||
/// `body` written to the file at `path`, ASCII or binary.
|
||||
pub(crate) fn write(body: &Body, path: &Path, ascii: bool) -> Result<()> {
|
||||
let bytes = with_body("smlib_brep_write", body, |k, b| bytes(k, b, ascii))?;
|
||||
std::fs::write(path, bytes).map_err(|e| io("writing", path, &e))
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
//! Face and edge history on solidcore: a port of the fork's `capi/src/smlib_history.cpp`
|
||||
//! (`smlib_history_tag`, `smlib_history_read`) onto solidcore's B-rep-owned attribute store
|
||||
//! ([`Brep::add_attribute`]), which solidcore's operations carry through their edits as SMLib's
|
||||
//! `SmAObject::Notify` does: Split when a face or edge is cut in two, Merge when two become one,
|
||||
//! Copy when an object is copied (a boolean copies the second operand into the first), Coincident
|
||||
//! where a boolean finds one operand's entity lying on the other's. New topology (a boolean's
|
||||
//! intersection edges) is made without any.
|
||||
//!
|
||||
//! The capi's `Tag` is one application attribute ([`Attribute::application`]) per face or edge,
|
||||
//! with the capi's own handlers ([`AttributeHandlers`]): a split child, a merge's result and the
|
||||
//! entity a coincidence lands on join the origins in, where SMLib's defaults keep only one; a copy
|
||||
//! is SMLib's default. The data, the handlers and the reading are the capi's.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use solidcore::geom::attribute::{
|
||||
ApplicationValue, Attribute, AttributeHandle, AttributeHandlers, AttributeValue, AttributeView, Behavior, ObjectKey, Placement, ids,
|
||||
};
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::query::api_queries::edge_closest_point;
|
||||
use solidcore::topology::face::p4 as face4;
|
||||
use solidcore::topology::{Brep, EdgeId, VertexId, edge};
|
||||
use solidcore::{EntityRef, Point3, SolidError};
|
||||
use solidcraft_geom::brep::history::Change;
|
||||
|
||||
use super::{Body, answer, with_body};
|
||||
use crate::history::{Raw, RawEdge, RawFace};
|
||||
use crate::{Error, Result};
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
|
||||
/// `TAG_ID`: above SMLib's own ids (all below `SM_AI_USER_1`).
|
||||
const TAG_ID: u64 = ids::USER_1 + 7;
|
||||
|
||||
/// Operand numbers and indices a tag accepts (`TAG_MAX`: keeps every value inside 30 bits).
|
||||
const TAG_MAX: usize = 1 << 30;
|
||||
|
||||
/// One (operand, index) pair a face or edge stands for.
|
||||
type Origin = (usize, usize);
|
||||
|
||||
/// The capi's `Tag`: what a face or edge stands for, the operand entities it came from, for a face
|
||||
/// tagged directly its own boundary edges then (to tell a face that is still whole), and for an
|
||||
/// edge tagged directly where it lay then (points along it, ends first: to tell an edge that
|
||||
/// moved). Copy behaviour (`SM_AB_COPY`); a copy (`MakeCopy`) is the same data.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
struct Tag {
|
||||
origins: BTreeSet<Origin>,
|
||||
edges: BTreeSet<i64>,
|
||||
at: Vec<Point3>,
|
||||
}
|
||||
|
||||
impl Tag {
|
||||
fn attribute(self) -> Attribute {
|
||||
Attribute::application(TAG_ID, Behavior::Copy, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Tag::on`: the tag on `x`, if the attribute of its id there is a [`Tag`].
|
||||
fn on<'a>(view: &AttributeView<'a>, x: ObjectKey) -> Option<(AttributeHandle, &'a Tag)> {
|
||||
let (h, a) = view.find_attribute(x, TAG_ID)?;
|
||||
a.application_value::<Tag>().map(|t| (h, t))
|
||||
}
|
||||
|
||||
/// `Tag::join`: adds `o` and `e` to `x`'s tag (its `at` as it was), giving it one with no `at` if
|
||||
/// it has none (an attribute of the tag's id that is no tag is replaced). Nothing for no `o`.
|
||||
fn join(view: &AttributeView<'_>, x: ObjectKey, o: &BTreeSet<Origin>, e: &BTreeSet<i64>) -> Vec<Placement> {
|
||||
if o.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some((handle, t)) = on(view, x) {
|
||||
let mut t = t.clone();
|
||||
t.origins.extend(o.iter().copied());
|
||||
t.edges.extend(e.iter().copied());
|
||||
return vec![Placement::Edit { handle, value: AttributeValue::Application(ApplicationValue::new(t)) }];
|
||||
}
|
||||
set(view, x, o.clone(), e.clone(), Vec::new())
|
||||
}
|
||||
|
||||
/// `Tag::set`: replaces whatever tag `x` has (the attribute of the tag's id) with exactly `o`, `e`
|
||||
/// and `p`.
|
||||
fn set(view: &AttributeView<'_>, x: ObjectKey, o: BTreeSet<Origin>, e: BTreeSet<i64>, p: Vec<Point3>) -> Vec<Placement> {
|
||||
let mut out = Vec::with_capacity(2);
|
||||
if let Some((handle, _)) = view.find_attribute(x, TAG_ID) {
|
||||
out.push(Placement::Remove { on: x, handle });
|
||||
}
|
||||
out.push(Placement::Add { on: x, attribute: Tag { origins: o, edges: e, at: p }.attribute() });
|
||||
out
|
||||
}
|
||||
|
||||
impl AttributeHandlers for Tag {
|
||||
/// A split: both children stand for what the split object stood for, and lie where it lay
|
||||
/// (each a piece of it): a child with no tag gets this one's `at` too.
|
||||
fn split(&self, view: &AttributeView<'_>, split: ObjectKey, a: ObjectKey, b: ObjectKey) -> solidcore::Result<Vec<Placement>> {
|
||||
let mut out = Vec::new();
|
||||
for x in [a, b] {
|
||||
if x == split {
|
||||
continue;
|
||||
}
|
||||
if on(view, x).is_none() && !self.origins.is_empty() {
|
||||
out.extend(set(view, x, self.origins.clone(), self.edges.clone(), self.at.clone()));
|
||||
} else {
|
||||
out.extend(join(view, x, &self.origins, &self.edges));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A merge: the result stands for both (the tags on the sources other than the result).
|
||||
fn merge(&self, view: &AttributeView<'_>, a: ObjectKey, b: ObjectKey, result: ObjectKey) -> solidcore::Result<Vec<Placement>> {
|
||||
let (mut o, mut e) = (BTreeSet::new(), BTreeSet::new());
|
||||
for x in [a, b] {
|
||||
if x != result
|
||||
&& let Some((_, t)) = on(view, x)
|
||||
{
|
||||
o.extend(t.origins.iter().copied());
|
||||
e.extend(t.edges.iter().copied());
|
||||
}
|
||||
}
|
||||
Ok(join(view, result, &o, &e))
|
||||
}
|
||||
|
||||
/// One operand's entity found lying on the other's, which survives: it stands for both.
|
||||
fn coincident(&self, view: &AttributeView<'_>, _from: ObjectKey, to: ObjectKey) -> solidcore::Result<Vec<Placement>> {
|
||||
Ok(join(view, to, &self.origins, &self.edges))
|
||||
}
|
||||
}
|
||||
|
||||
/// `read`: what `e`'s tag says (the attribute of the tag's id, if it is a [`Tag`]).
|
||||
fn tag_on(b: &Brep, e: EntityRef) -> Option<&Tag> {
|
||||
let (_, a) = b.entity_attributes(e).into_iter().find(|(_, a)| a.attribute_id() == TAG_ID)?;
|
||||
a.application_value::<Tag>()
|
||||
}
|
||||
|
||||
/// `Tag::set` at tagging: replaces whatever tag `e` has with exactly origin `o`, the boundary
|
||||
/// edges `edges` (a face's) and the points `at` (an edge's).
|
||||
fn set_on(b: &mut Brep, e: EntityRef, o: Origin, edges: BTreeSet<i64>, at: Vec<Point3>) -> std::result::Result<(), SolidError> {
|
||||
let old = b.entity_attributes(e).into_iter().find(|(_, a)| a.attribute_id() == TAG_ID).map(|(h, _)| h);
|
||||
if let Some(h) = old {
|
||||
b.remove_attribute(e, h)?;
|
||||
}
|
||||
b.add_attribute(e, Tag { origins: BTreeSet::from([o]), edges, at }.attribute())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `samples`: points along `e`, its two ends, then three between, evenly in its parameter. Empty
|
||||
/// where it can't be evaluated.
|
||||
fn samples(k: &Kernel, b: &Brep, e: EdgeId) -> std::result::Result<Vec<Point3>, SolidError> {
|
||||
let edge = b.edge(e)?;
|
||||
let Some(curve) = edge.curve.as_ref() else { return Ok(Vec::new()) };
|
||||
let (lo, hi) = (edge.interval.min(), edge.interval.max());
|
||||
let mut p = Vec::with_capacity(5);
|
||||
for t in [0.0, 1.0, 0.25, 0.5, 0.75] {
|
||||
let Some(v) = answer(curve.evaluate(k, lo + t * (hi - lo), 0, true, false))? else { return Ok(Vec::new()) };
|
||||
let Some(v) = v.first() else { return Ok(Vec::new()) };
|
||||
p.push(Point3::new(v.x, v.y, v.z));
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// `moved`: have `pieces`, the result edges standing for one edge, moved from where its tag says
|
||||
/// it lay (`at`, the first piece's record of at least two points)? Together they must run between
|
||||
/// its ends (the vertices only one piece ends on; none for an edge closed on itself, whose end some
|
||||
/// piece still ends on), and every point it ran through must lie on one of them (within `tol`).
|
||||
/// One piece is the edge still whole; more, the edge cut by vertices. Pieces with no record of
|
||||
/// where the edge lay have not moved.
|
||||
pub(super) fn moved(k: &Kernel, b: &Brep, pieces: &[(EdgeId, &[Point3])], tol: f64) -> std::result::Result<bool, SolidError> {
|
||||
let Some(at) = pieces.iter().map(|(_, at)| *at).find(|at| at.len() >= 2) else { return Ok(false) };
|
||||
let [a, z, ..] = at else { return Ok(false) };
|
||||
let near = |p: &Point3, q: &Point3| p.distance(*q) <= tol;
|
||||
let mut ends: BTreeMap<VertexId, usize> = BTreeMap::new();
|
||||
for (e, _) in pieces {
|
||||
let (Some(s), Some(f)) = edge::start_end_vertices(b, *e)? else { return Ok(true) };
|
||||
*ends.entry(s).or_default() += 1;
|
||||
*ends.entry(f).or_default() += 1;
|
||||
}
|
||||
let mut open = Vec::new();
|
||||
for (v, n) in &ends {
|
||||
if n % 2 == 1 {
|
||||
open.push(b.vertex(*v)?.point);
|
||||
}
|
||||
}
|
||||
let span = match open.as_slice() {
|
||||
[p, q] => (near(p, a) && near(q, z)) || (near(p, z) && near(q, a)),
|
||||
[] => {
|
||||
let mut on_end = false;
|
||||
for v in ends.keys() {
|
||||
on_end = on_end || near(&b.vertex(*v)?.point, a);
|
||||
}
|
||||
near(a, z) && on_end
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !span {
|
||||
return Ok(true);
|
||||
}
|
||||
for q in at.iter().skip(2) {
|
||||
// `SmApiEdgeClosestPoint` failing, however it fails, counts as off a piece, as a distance
|
||||
// above `tol` does: the face is then Modified, and the operation stands.
|
||||
if !pieces.iter().any(|(e, _)| edge_closest_point(k, b, *e, *q).is_ok_and(|c| c.closest.distance <= tol)) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// `smlib_history_tag`: a copy of `body` (`SmApiBrepCopy`) whose face i and edge i stand for face
|
||||
/// i and edge i of operand `operand` (in its face and edge order, which the copy keeps), a face
|
||||
/// also recording its boundary edges then, an edge the points it ran through.
|
||||
pub(crate) fn tag(body: &Body, operand: usize) -> Result<Body> {
|
||||
if operand >= TAG_MAX {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
with_body("smlib_history_tag", body, |k, b| {
|
||||
let mut r = brep_copy(k, b)?;
|
||||
let faces = r.face_list().to_vec();
|
||||
let edges = r.edge_list().to_vec();
|
||||
if faces.len() >= TAG_MAX || edges.len() >= TAG_MAX {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
let edge_at: HashMap<EdgeId, usize> = edges.iter().enumerate().map(|(i, e)| (*e, i)).collect();
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let own: BTreeSet<i64> =
|
||||
face4::edges(&r, *f, None, false, None)?.iter().filter_map(|e| edge_at.get(e).and_then(|j| i64::try_from(*j).ok())).collect();
|
||||
set_on(&mut r, EntityRef::Face(*f), (operand, i), own, Vec::new())?;
|
||||
}
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
let at = samples(k, &r, *e)?;
|
||||
set_on(&mut r, EntityRef::Edge(*e), (operand, i), BTreeSet::new(), at)?;
|
||||
}
|
||||
Ok(Ok(r))
|
||||
})?
|
||||
.map(Body::new)
|
||||
}
|
||||
|
||||
/// `smlib_history_read`: each face's and edge's origins in `result`, a face's change from how
|
||||
/// many origins it has, how many result faces share them and whether its boundary is still
|
||||
/// exactly its origin's edges; each edge's faces. In the body's own face and edge order.
|
||||
pub(crate) fn read(result: &Body) -> Result<Raw> {
|
||||
with_body("smlib_history_read", result, |k, b| {
|
||||
let faces = b.face_list().to_vec();
|
||||
let edges = b.edge_list().to_vec();
|
||||
let face_at: HashMap<_, usize> = faces.iter().enumerate().map(|(i, f)| (*f, i)).collect();
|
||||
let edge_at: HashMap<EdgeId, usize> = edges.iter().enumerate().map(|(i, e)| (*e, i)).collect();
|
||||
let tol = b.tolerance();
|
||||
|
||||
// Each face's origins and the boundary edges they had; each edge's origins and points.
|
||||
let mut fo: Vec<BTreeSet<Origin>> = Vec::with_capacity(faces.len());
|
||||
let mut fe: Vec<BTreeSet<usize>> = Vec::with_capacity(faces.len());
|
||||
let mut eo: Vec<BTreeSet<Origin>> = Vec::with_capacity(edges.len());
|
||||
let mut at: Vec<Vec<Point3>> = Vec::with_capacity(edges.len());
|
||||
let mut face_uses: BTreeMap<Origin, usize> = BTreeMap::new();
|
||||
let mut edge_uses: BTreeMap<Origin, usize> = BTreeMap::new();
|
||||
for f in &faces {
|
||||
let (o, e) = tag_on(b, EntityRef::Face(*f)).map(|t| (t.origins.clone(), t.edges.clone())).unwrap_or_default();
|
||||
for x in &o {
|
||||
*face_uses.entry(*x).or_default() += 1;
|
||||
}
|
||||
fe.push(e.into_iter().filter_map(|x| usize::try_from(x).ok()).collect());
|
||||
fo.push(o);
|
||||
}
|
||||
for e in &edges {
|
||||
let (o, p) = tag_on(b, EntityRef::Edge(*e)).map(|t| (t.origins.clone(), t.at.clone())).unwrap_or_default();
|
||||
for x in &o {
|
||||
*edge_uses.entry(*x).or_default() += 1;
|
||||
}
|
||||
at.push(p);
|
||||
eo.push(o);
|
||||
}
|
||||
|
||||
let mut out_faces = Vec::with_capacity(faces.len());
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let (Some(o), Some(own)) = (fo.get(i), fe.get(i)) else { continue };
|
||||
let change = match o.iter().next() {
|
||||
None => Change::Generated,
|
||||
Some(_) if o.len() > 1 => Change::Merged,
|
||||
Some(first) if face_uses.get(first).copied().unwrap_or(0) > 1 => Change::Split,
|
||||
Some(first) => {
|
||||
// Kept when its boundary is still exactly where the edges it had were: each
|
||||
// boundary edge one of its own operand's, standing for one edge only, every
|
||||
// piece of that edge on this face (an edge only cut by a vertex, as splitting
|
||||
// the face beside it cuts it, is still all there), the pieces where it lay (an
|
||||
// edge an operation moved, as a draft moves a tilted face's neighbours' edges,
|
||||
// leaves those faces trimmed differently), and those edges exactly the ones it
|
||||
// had.
|
||||
let mut pieces: BTreeMap<usize, Vec<(EdgeId, &[Point3])>> = BTreeMap::new();
|
||||
let mut whole = true;
|
||||
for e in face4::edges(b, *f, None, false, None)? {
|
||||
let Some(j) = edge_at.get(&e).copied() else {
|
||||
whole = false;
|
||||
break;
|
||||
};
|
||||
let (Some(origins), Some(points)) = (eo.get(j), at.get(j)) else {
|
||||
whole = false;
|
||||
break;
|
||||
};
|
||||
let only = match origins.iter().next() {
|
||||
Some(x) if origins.len() == 1 => *x,
|
||||
_ => {
|
||||
whole = false;
|
||||
break;
|
||||
}
|
||||
};
|
||||
whole = only.0 == first.0;
|
||||
if !whole {
|
||||
break;
|
||||
}
|
||||
let p = pieces.entry(only.1).or_default();
|
||||
if !p.iter().any(|(x, _)| *x == e) {
|
||||
p.push((e, points.as_slice()));
|
||||
}
|
||||
}
|
||||
let mut now = BTreeSet::new();
|
||||
for (edge, p) in &pieces {
|
||||
whole = whole && p.len() == edge_uses.get(&(first.0, *edge)).copied().unwrap_or(0) && !moved(k, b, p, tol)?;
|
||||
now.insert(*edge);
|
||||
}
|
||||
if whole && now == *own { Change::Kept } else { Change::Modified }
|
||||
}
|
||||
};
|
||||
out_faces.push(RawFace { change, origins: o.iter().copied().collect() });
|
||||
}
|
||||
|
||||
let mut out_edges = Vec::with_capacity(edges.len());
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
let bounds: BTreeSet<usize> = edge::faces(b, *e, None)?.iter().filter_map(|f| face_at.get(f).copied()).collect();
|
||||
let origins = eo.get(i).map(|o| o.iter().copied().collect()).unwrap_or_default();
|
||||
out_edges.push(RawEdge { faces: bounds.into_iter().collect(), origins });
|
||||
}
|
||||
Ok(Raw { faces: out_faces, edges: out_edges })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! The Measure and Check families on solidcore: what `capi/src/smlib_measure.cpp`,
|
||||
//! `smlib_brep_solid_count` (`smlib_ops.cpp`), `smlib_brep_is_manifold_solid`
|
||||
//! (`smlib_flat_api.cpp`) and `smlib_brep_check` (`smlib_check.cpp`) do on the C++ kernel, here
|
||||
//! composed from solidcore's ports of the same SMLib routines.
|
||||
//!
|
||||
//! [`check`] fails with "not supported yet" while a test it reaches is unported in solidcore.
|
||||
|
||||
use solidcore::error::EntityRef;
|
||||
use solidcore::geom::assert_array::{AssertArray, AssertReport, obj_type_string};
|
||||
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
|
||||
use solidcore::geom::tol::{SmallTopoType, brep_zone_tol_3d, context_xsect_tol_3d};
|
||||
use solidcore::heal::gap::GapFunction;
|
||||
use solidcore::intersect::point_class::PointClassType;
|
||||
use solidcore::mass::integrator::properties::{MassPropertyBehavior, PropertyAttributes, brep_properties, face_properties};
|
||||
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
|
||||
use solidcore::query::api_queries::{brep_bounding_box, brep_is_manifold_solid, brep_material_census, face_bounding_box};
|
||||
use solidcore::query::brep::BrepClassifier;
|
||||
use solidcore::topology::brep::p1 as bl;
|
||||
use solidcore::topology::brep::validation::{CheckLevel as SolidcoreCheckLevel, check_brep};
|
||||
use solidcore::topology::face::p2::{PPF_AREA, PPF_AREA_FIRST_MOMENTS};
|
||||
use solidcore::topology::{FaceId, FaceuseId, NamingScheme};
|
||||
use solidcore::{Kernel, Point3, SolidError, Vec3};
|
||||
|
||||
use super::blend::status;
|
||||
use super::{Body, with_body};
|
||||
use crate::check::{CheckLevel, CheckReport};
|
||||
use crate::measure::{MassProperties, PointClass};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OP: &str = "smlib_measure";
|
||||
|
||||
/// The accuracy the C API passes on: finite, clamped to [1e-8, 1e-1].
|
||||
fn accuracy(relative_accuracy: f64) -> Result<f64> {
|
||||
if !relative_accuracy.is_finite() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
Ok(relative_accuracy.clamp(1e-8, 1e-1))
|
||||
}
|
||||
|
||||
fn invalid(why: &'static str) -> SolidError {
|
||||
SolidError::invalid_input(OP, why)
|
||||
}
|
||||
|
||||
fn finite(v: &[f64]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// `faces_area` of `smlib_measure.cpp`: every face counted once (SmApiBrepComputeArea, with the
|
||||
/// [1e-8, 1e-1] envelope instead of its [1e-4, 1e-1]), the total area and, when `first` is set,
|
||||
/// the area's first moments about `o`. With `only_sheets`, only [`sheet_face`]s (the capi's
|
||||
/// `counted`).
|
||||
fn faces_area(k: &Kernel, b: &mut solidcore::Brep, accuracy: f64, o: Point3, first: bool, only_sheets: bool) -> solidcore::Result<(f64, Vec3)> {
|
||||
let mut area = 0.0;
|
||||
let mut sum = Vec3::new(0.0, 0.0, 0.0);
|
||||
let flags = if first { PPF_AREA | PPF_AREA_FIRST_MOMENTS } else { PPF_AREA };
|
||||
for f in bl::faces(b, None)? {
|
||||
if only_sheets && !sheet_face(b, f)? {
|
||||
continue;
|
||||
}
|
||||
let size = face_bounding_box(k, b, f, false)?.size();
|
||||
let edge = size.z.max(size.x.max(size.y));
|
||||
let p = face_properties(k, b, f, OrientType::Same, accuracy, o, edge * edge / 100.0, 0.0, flags)?;
|
||||
// The area and its moments share one integrand, so a trim boundary integrated the other
|
||||
// way round flips both.
|
||||
let sign = if p.area < 0.0 { -1.0 } else { 1.0 };
|
||||
area += sign * p.area;
|
||||
if first {
|
||||
sum += p.moments[0] * sign;
|
||||
}
|
||||
}
|
||||
if !area.is_finite() {
|
||||
return Err(invalid("non-finite area"));
|
||||
}
|
||||
Ok((area, sum))
|
||||
}
|
||||
|
||||
/// `sheet_face`: a face with material on neither side, standing free or hung on a solid (a blend
|
||||
/// sheet stitched between two boxes). `SmBrep::ComputePreciseProperties` takes its area only from
|
||||
/// faces between a solid region and a void, so such a face is counted on its own.
|
||||
fn sheet_face(b: &solidcore::Brep, f: FaceId) -> solidcore::Result<bool> {
|
||||
let void_side = |u: Option<FaceuseId>| -> solidcore::Result<bool> {
|
||||
let Some(u) = u else { return Ok(true) };
|
||||
let Some(shell) = b.faceuse(u)?.shell else { return Ok(true) };
|
||||
let Some(region) = b.shell(shell)?.region else { return Ok(true) };
|
||||
// IsVoid's UNSURE (2) is true in C++: only FALSE is material.
|
||||
Ok(b.region(region)?.is_void() != 0)
|
||||
};
|
||||
let (x, y) = solidcore::topology::face::p2::faceuses(b, f)?;
|
||||
Ok(void_side(x)? && void_side(y)?)
|
||||
}
|
||||
|
||||
/// `smlib_brep_mass_properties`: `SmBrep::ComputePreciseProperties` directly, as the C API calls
|
||||
/// it (not `SmApiBrepComputeMassProperties`: the [1e-8, 1e-1] accuracy envelope, no manifold
|
||||
/// refusal, the sum over material regions), the face-by-face area and its centroid for a body
|
||||
/// with no material, and moments about the middle of the box moved to the centroid. A body's area
|
||||
/// is every face's: the solid's boundary and its sheet faces (fork 9767916).
|
||||
pub(crate) fn mass_properties(body: &Body, relative_accuracy: f64) -> Result<MassProperties> {
|
||||
let accuracy = accuracy(relative_accuracy)?;
|
||||
with_body("mass_properties", body, |k, b| {
|
||||
let mut material = false;
|
||||
for r in bl::regions(b, None)? {
|
||||
// IsVoid's UNSURE (2) is true in C++: only FALSE is material.
|
||||
material |= b.region(r)?.is_void() == 0;
|
||||
}
|
||||
// The C API refuses a body with per-entity mass attributes (they would override the unit
|
||||
// density). Bodies here carry none: the attribute store passed in is empty.
|
||||
let store = AttributeStore::default();
|
||||
let attrs = PropertyAttributes { store: &store, key_of: &|_| ObjectKey(0) };
|
||||
// Moments about the middle of the box, moved to the centroid below: about the world
|
||||
// origin, a body far from it would lose its inertia to cancellation.
|
||||
let o = brep_bounding_box(k, b, false)?.mid();
|
||||
if !finite(&[o.x, o.y, o.z]) {
|
||||
return Err(invalid("non-finite box"));
|
||||
}
|
||||
if !material {
|
||||
let (area, first) = faces_area(k, b, accuracy, o, true, false)?;
|
||||
if !(area > 0.0) {
|
||||
return Err(invalid("no area"));
|
||||
}
|
||||
let c = [o.x + first.x / area, o.y + first.y / area, o.z + first.z / area];
|
||||
if !finite(&[area, c[0], c[1], c[2]]) {
|
||||
return Err(invalid("non-finite result"));
|
||||
}
|
||||
return Ok(MassProperties { area, volume: 0.0, centroid: c, moments: [0.0; 3], products: [0.0; 3] });
|
||||
}
|
||||
let p = brep_properties(k, b, accuracy, o, MassPropertyBehavior { density: 1.0, ..MassPropertyBehavior::default() }, &attrs)?;
|
||||
let (mass, volume) = (p.mass, p.volume);
|
||||
if !mass.is_finite() || !(mass > 0.0) || !(volume > 0.0) {
|
||||
return Err(invalid("no mass"));
|
||||
}
|
||||
// m[4]: first moments about o; m[7]: (y²+z², z²+x², x²+y²) about o; m[6]: (yz, zx, xy)
|
||||
// about o. Parallel axes from o to the centroid (unit density: mass = volume).
|
||||
let [_, _, _, _, m4, _, m6, m7] = p.moments;
|
||||
let d = [m4.x / mass, m4.y / mass, m4.z / mass];
|
||||
let (sheets, _) = faces_area(k, b, accuracy, o, false, true)?;
|
||||
let r = MassProperties {
|
||||
area: p.area + sheets,
|
||||
volume,
|
||||
centroid: [o.x + d[0], o.y + d[1], o.z + d[2]],
|
||||
moments: [
|
||||
m7.x - mass * (d[1] * d[1] + d[2] * d[2]),
|
||||
m7.y - mass * (d[0] * d[0] + d[2] * d[2]),
|
||||
m7.z - mass * (d[0] * d[0] + d[1] * d[1]),
|
||||
],
|
||||
products: [m6.x - mass * d[1] * d[2], m6.y - mass * d[2] * d[0], m6.z - mass * d[0] * d[1]],
|
||||
};
|
||||
if !finite(&[r.area, r.volume]) || !finite(&r.centroid) || !finite(&r.moments) || !finite(&r.products) {
|
||||
return Err(invalid("non-finite result"));
|
||||
}
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
/// `smlib_brep_area`: every face counted once.
|
||||
pub(crate) fn area(body: &Body, relative_accuracy: f64) -> Result<f64> {
|
||||
let accuracy = accuracy(relative_accuracy)?;
|
||||
with_body("area", body, |k, b| faces_area(k, b, accuracy, Point3::new(0.0, 0.0, 0.0), false, false).map(|(a, _)| a))
|
||||
}
|
||||
|
||||
/// `smlib_brep_bounds`: `SmBrep::CalculateTightBoundingBox`.
|
||||
pub(crate) fn bounds(body: &Body) -> Result<([f64; 3], [f64; 3])> {
|
||||
with_body("bounds", body, |k, b| {
|
||||
let x = brep_bounding_box(k, b, true)?;
|
||||
let (lo, hi) = (x.min(), x.max());
|
||||
let (lo, hi) = ([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
if !finite(&lo) || !finite(&hi) || lo.iter().zip(&hi).any(|(a, b)| a > b) {
|
||||
return Err(invalid("no box"));
|
||||
}
|
||||
Ok((lo, hi))
|
||||
})
|
||||
}
|
||||
|
||||
/// `smlib_brep_edge_face_distance`: SMLib's own edge/face gap (`SmEdgeuse::CalcMaxEdgeFaceGap`'s
|
||||
/// `SmCrvSrfGapFunction` with 20 samples, its largest sample). Indices in the body's edge and
|
||||
/// face order.
|
||||
pub(crate) fn edge_face_distance(body: &Body, edge: usize, face: usize) -> Result<f64> {
|
||||
let found = with_body("edge_face_distance", body, |k, b| {
|
||||
let (Some(&e), Some(&f)) = (bl::edges(b, None)?.get(edge), bl::faces(b, None)?.get(face)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let interval = b.edge(e)?.interval;
|
||||
let (Some(curve), Some(surface)) = (b.edge(e)?.curve.as_ref(), b.face(f)?.surface.as_ref()) else {
|
||||
return Err(invalid("edge without curve or face without surface"));
|
||||
};
|
||||
if !(interval.max() > interval.min()) {
|
||||
return Err(invalid("empty edge interval"));
|
||||
}
|
||||
// SmTol::GetXSectTol3d(brep's context): the process context. The tolerance only
|
||||
// classifies the samples; it does not change the distances.
|
||||
let tol = context_xsect_tol_3d(k, None, SmallTopoType::NormalSize);
|
||||
let mut gaps = GapFunction::crv_srf(tol, curve, &interval, surface, 20);
|
||||
let d = gaps.max_gap_sample(k, false)?.length();
|
||||
if !d.is_finite() {
|
||||
return Err(invalid("non-finite gap"));
|
||||
}
|
||||
Ok(Some(d))
|
||||
})?;
|
||||
found.ok_or(Error::Argument)
|
||||
}
|
||||
|
||||
/// `smlib_brep_classify_point`: `SmBrep::Point3DClassify` at the body's own zone tolerance
|
||||
/// (`SmTol::GetZoneTol3d` of the body), with boundary intersections, so a point within that
|
||||
/// tolerance of a vertex, an edge or a face is on the boundary and never decided by a ray; else
|
||||
/// the region it lies in, outside when that region is void (`IsVoid() == TRUE`; `UNSURE` is
|
||||
/// material, as the capi compares with `TRUE`). Runs on a private copy when a solve needs one
|
||||
/// (`BrepClassifier`): upstream's `const` method may add UV trim curves to the body.
|
||||
pub(crate) fn classify_point(body: &Body, p: [f64; 3]) -> Result<PointClass> {
|
||||
if !finite(&p) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
with_body("classify_point", body, |k, b| {
|
||||
let tol = brep_zone_tol_3d(k, b, SmallTopoType::NormalSize);
|
||||
let mut classifier = BrepClassifier::of_brep(b);
|
||||
let pc = match classifier.point_3d_classify(k, Point3::new(p[0], p[1], p[2]), tol, true) {
|
||||
Ok(pc) => pc,
|
||||
// Its status, as the capi passes it on (no ray finding a region is SM_ERR).
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
Ok(match (pc.point_class(), pc.object()) {
|
||||
(PointClassType::Vertex | PointClassType::Edge | PointClassType::Face, _) => Ok(PointClass::OnBoundary),
|
||||
(PointClassType::Region, Some(EntityRef::Region(r))) => {
|
||||
Ok(if classifier.brep().region(r)?.is_void() == 1 { PointClass::Outside } else { PointClass::Inside })
|
||||
}
|
||||
// Point3DClassify reports only the four classes above on success: SM_ERR.
|
||||
_ => Err(Error::Status(SM_ERR)),
|
||||
})
|
||||
})?
|
||||
}
|
||||
|
||||
/// `SM_ERR`.
|
||||
const SM_ERR: i32 = 1001;
|
||||
|
||||
/// `smlib_brep_is_manifold_solid`: `SmApiBrepIsManifoldSolid`.
|
||||
pub(crate) fn is_manifold_solid(body: &Body) -> Result<bool> {
|
||||
with_body("is_manifold_solid", body, |_, b| brep_is_manifold_solid(b))
|
||||
}
|
||||
|
||||
/// `smlib_brep_solid_count`: the solids of `SmApiBrepMaterialCensus` (its voids are dropped).
|
||||
pub(crate) fn solid_count(body: &Body) -> Result<usize> {
|
||||
with_body("solid_count", body, |_, b| brep_material_census(b).map(|(solids, _voids)| solids))
|
||||
}
|
||||
|
||||
/// SMLib's `SM_SUCCESS`, the status `SmBrep::ValidatePointers` returns when it finds nothing.
|
||||
const SM_SUCCESS: i32 = 1000;
|
||||
|
||||
/// The capi's `text()`: an empty string reads as `?`.
|
||||
fn text(s: &str) -> &str {
|
||||
if s.is_empty() { "?" } else { s }
|
||||
}
|
||||
|
||||
/// The capi's `line()`: `<reporting type>::Test #<n> <name>: <message> [owner <type>]`, the owner
|
||||
/// tag only when the report names the owner's type.
|
||||
fn report_line(r: &AssertReport) -> String {
|
||||
let msg = r.format_log_message();
|
||||
let mut s = format!("{}::Test #{} {}: {}", text(obj_type_string(r.reporting_type)), r.test_index, text(r.name), text(&msg));
|
||||
if let Some(owner) = r.owner_type_string.filter(|o| !o.is_empty()) {
|
||||
s.push_str(" [owner ");
|
||||
s.push_str(owner);
|
||||
s.push(']');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The capi's `collect()`: appends the failed reports of `list`, one line each, and counts them;
|
||||
/// SMLib's warnings ([`AssertReport::is_warning`]: `SmBrep` test 21, "Suspicious Shell Count", a
|
||||
/// body of several faces with one shell each, is one) are neither.
|
||||
fn collect(list: &AssertArray, out: &mut String, failures: &mut usize) {
|
||||
for r in list.reports().iter().filter(|r| !r.ok && !r.is_warning()) {
|
||||
*failures += 1;
|
||||
out.push_str(&report_line(r));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/// Why [`check`] could not run.
|
||||
enum Refusal {
|
||||
/// A test solidcore has not ported (its [`SolidError::Unported`]).
|
||||
Unported(&'static str, &'static str),
|
||||
/// A body whose geometry owners' identities the check cannot know: a
|
||||
/// [`NamingScheme::Unknown`] body.
|
||||
Naming(NamingScheme),
|
||||
}
|
||||
|
||||
impl<T> super::dump::Answer for std::result::Result<T, Refusal> {
|
||||
fn refusal(&self) -> Option<String> {
|
||||
self.is_err().then(|| "the check could not run".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// `smlib_brep_check` (`smlib_check.cpp`): `SmBrep::ValidatePointers` at `level`, then, when it
|
||||
/// failed nothing, `SmBrep::AssertValid` at `level` over the whole topology graph, with every
|
||||
/// failed test as one line of the report and the capi's own lines for a failed status without a
|
||||
/// report. solidcore's [`check_brep`] runs the same two calls.
|
||||
///
|
||||
/// The geometry owners are named as the body's naming scheme names them: `check_brep` reads a
|
||||
/// [`NamingScheme::Caller`] body's keys from where its maker recorded them (solidcore#1585: an
|
||||
/// array import through `make_topology_from_data`, `api_brep_copy`); where it recorded none, the
|
||||
/// arena identities the binders name such an entity by. A [`NamingScheme::Unknown`] body would be
|
||||
/// checked under arena identities no maker uses: every edge would fail `SmBrep` test 61 on the
|
||||
/// owner of its curve.
|
||||
///
|
||||
/// A test solidcore has not ported yet (its [`SolidError::Unported`]), or a body whose geometry
|
||||
/// owners' identities the check cannot know, fails the check with "not supported yet": the check
|
||||
/// never reports a body valid on fewer tests than the C++ kernel runs.
|
||||
pub(crate) fn check(body: &Body, level: CheckLevel) -> Result<CheckReport> {
|
||||
let lv = match level {
|
||||
CheckLevel::Basic => SolidcoreCheckLevel::Level0,
|
||||
CheckLevel::Geometry => SolidcoreCheckLevel::Level1,
|
||||
CheckLevel::All => SolidcoreCheckLevel::Level2,
|
||||
};
|
||||
let c = match with_body("check", body, |k, b| {
|
||||
let naming = b.naming();
|
||||
if naming == NamingScheme::Unknown {
|
||||
return Ok(Err(Refusal::Naming(naming)));
|
||||
}
|
||||
match check_brep(k, b, lv) {
|
||||
Err(SolidError::Unported { upstream, unit }) => Ok(Err(Refusal::Unported(upstream, unit))),
|
||||
r => r.map(Ok),
|
||||
}
|
||||
})? {
|
||||
Ok(c) => c,
|
||||
Err(Refusal::Unported(upstream, unit)) => {
|
||||
return Err(Error::Solidcore(format!("smlib_brep_check: {upstream}: not supported yet: solidcore has not ported it (unit {unit})")));
|
||||
}
|
||||
Err(Refusal::Naming(n)) => {
|
||||
return Err(Error::Solidcore(format!(
|
||||
"smlib_brep_check: not supported yet: the body names its geometry owners by its maker's keys (NamingScheme::{n:?}), which the check cannot know"
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut text = String::new();
|
||||
let mut failures = 0usize;
|
||||
collect(&c.pointer_reports, &mut text, &mut failures);
|
||||
if c.pointers_status != SM_SUCCESS && failures == 0 {
|
||||
failures += 1;
|
||||
text.push_str(&format!("SmBrep::ValidatePointers failed with status {}\n", c.pointers_status));
|
||||
}
|
||||
if failures > 0 {
|
||||
// The capi does not run AssertValid after a failed pointer check. check_brep runs it when
|
||||
// the status failed without a report; that result is dropped here, as the capi never
|
||||
// computes it.
|
||||
text.push_str("AssertValid skipped: ValidatePointers failed\n");
|
||||
} else {
|
||||
let Some(ok) = c.assert_valid else {
|
||||
return Err(Error::Solidcore("smlib_brep_check: AssertValid did not run after a clean pointer check".into()));
|
||||
};
|
||||
collect(&c.reports, &mut text, &mut failures);
|
||||
if !ok && failures == 0 {
|
||||
failures += 1;
|
||||
text.push_str("SmBrep::AssertValid failed without a report\n");
|
||||
}
|
||||
}
|
||||
Ok(CheckReport { failures, text })
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
//! The Measure and Check families on solidcore's primitives: mass properties, area and bounds
|
||||
//! against their closed forms ([`crate::measure::closed`]), one solid each, edges on their faces;
|
||||
//! and (with SMLib built) the same answers as the C++ kernel for the same primitive.
|
||||
|
||||
use super::{Body, area, bounds, check, edge_face_distance, is_manifold_solid, make_box, make_cone, make_cylinder, make_sphere, make_torus};
|
||||
use super::{counts, mass_properties, solid_count};
|
||||
use crate::check::CheckLevel;
|
||||
use crate::measure::MEASURE_ACCURACY;
|
||||
use crate::measure::closed::{self, Closed};
|
||||
|
||||
/// The tolerance every quantity meets, relative.
|
||||
const TOL: f64 = 1e-9;
|
||||
|
||||
/// The primitives, as `convert_tests` makes them, with their closed forms.
|
||||
fn case(name: &str) -> (Body, Closed) {
|
||||
let (body, c) = match name {
|
||||
"box" => (make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]), closed::cuboid([1.0, -2.0, 3.0], [10.0, 20.0, 30.0])),
|
||||
"cylinder" => (make_cylinder([1.0, 2.0, -3.0], 5.0, 12.0), closed::cylinder([1.0, 2.0, -3.0], 5.0, 12.0)),
|
||||
"sphere" => (make_sphere([1.0, 2.0, 3.0], 7.0), closed::sphere([1.0, 2.0, 3.0], 7.0)),
|
||||
"cone" => (make_cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0), closed::cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0)),
|
||||
"frustum" => (make_cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0), closed::cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0)),
|
||||
"torus" => (make_torus([1.0, -1.0, 0.5], 10.0, 3.0), closed::torus([1.0, -1.0, 0.5], 10.0, 3.0)),
|
||||
_ => panic!("{name}"),
|
||||
};
|
||||
(body.unwrap(), c)
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
/// The body's size: twice its largest radius of gyration, the scale of centroid errors.
|
||||
fn size(c: &Closed) -> f64 {
|
||||
2.0 * (c.moments.iter().fold(0.0f64, |a, &b| a.max(b)) / c.volume).sqrt()
|
||||
}
|
||||
|
||||
fn mass_properties_match(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let m = mass_properties(&b, MEASURE_ACCURACY).unwrap();
|
||||
assert!(rel(m.area, c.area) < TOL, "{name}: area {} vs {}", m.area, c.area);
|
||||
assert!(rel(m.volume, c.volume) < TOL, "{name}: volume {} vs {}", m.volume, c.volume);
|
||||
let imax = c.moments.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
for i in 0..3 {
|
||||
assert!((m.centroid[i] - c.centroid[i]).abs() < TOL * size(&c), "{name}: centroid {:?} vs {:?}", m.centroid, c.centroid);
|
||||
assert!(rel(m.moments[i], c.moments[i]) < TOL, "{name}: moments {:?} vs {:?}", m.moments, c.moments);
|
||||
assert!(m.products[i].abs() < TOL * imax, "{name}: products {:?}", m.products);
|
||||
}
|
||||
}
|
||||
|
||||
fn area_matches(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let a = area(&b, MEASURE_ACCURACY).unwrap();
|
||||
assert!(rel(a, c.area) < TOL, "{name}: area {a} vs {}", c.area);
|
||||
}
|
||||
|
||||
fn bounds_match(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let (lo, hi) = bounds(&b).unwrap();
|
||||
let s = size(&c);
|
||||
for i in 0..3 {
|
||||
assert!((lo[i] - c.bounds.0[i]).abs() < TOL * s, "{name}: low {lo:?} vs {:?}", c.bounds.0);
|
||||
assert!((hi[i] - c.bounds.1[i]).abs() < TOL * s, "{name}: high {hi:?} vs {:?}", c.bounds.1);
|
||||
}
|
||||
}
|
||||
|
||||
fn one_solid(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
assert_eq!(solid_count(&b).unwrap(), 1, "{name}");
|
||||
}
|
||||
|
||||
fn manifold_solid(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
assert!(is_manifold_solid(&b).unwrap(), "{name}");
|
||||
}
|
||||
|
||||
/// Every edge lies on the faces it bounds: its distance to the nearest face is 0.
|
||||
fn edges_lie_on_their_faces(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let [faces, edges, _] = counts(&b).unwrap();
|
||||
for e in 0..edges {
|
||||
let mut d: Vec<f64> = (0..faces).filter_map(|f| edge_face_distance(&b, e, f).ok()).collect();
|
||||
d.sort_by(f64::total_cmp);
|
||||
assert!(d.first().is_some_and(|&d| d < TOL * size(&c)), "{name}: edge {e}: {d:?}");
|
||||
}
|
||||
assert!(edge_face_distance(&b, edges, 0).is_err() && edge_face_distance(&b, 0, faces).is_err());
|
||||
}
|
||||
|
||||
fn checks_valid(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] {
|
||||
let r = check(&b, level).unwrap();
|
||||
assert!(r.is_valid(), "{name}, {level:?}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Refusing is decided on the body's naming scheme alone. An Unknown body is refused rather than
|
||||
/// reported invalid on every edge; a Caller one without recorded keys is checked under the arena
|
||||
/// identities (`check_brep` has no error for it, so no error of its can turn into a refusal).
|
||||
#[test]
|
||||
fn check_refuses_only_an_unknown_naming() {
|
||||
use solidcore::topology::NamingScheme;
|
||||
let name = |naming| {
|
||||
let (b, _) = case("box");
|
||||
b.0.lock().unwrap().set_naming(naming);
|
||||
check(&b, CheckLevel::All)
|
||||
};
|
||||
let refused = name(NamingScheme::Unknown).unwrap_err().to_string();
|
||||
assert!(refused.contains("not supported yet") && refused.contains("NamingScheme::Unknown"), "{refused}");
|
||||
name(NamingScheme::Caller).unwrap();
|
||||
}
|
||||
|
||||
/// The C++ kernel's body of the same primitive, built by its C API whichever kernel is in
|
||||
/// force: a body imported from solidcore's arrays is not the C++ kernel's own (its
|
||||
/// `edge_face_distance` measures to each face's surface over its domain, which the import sets).
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp(name: &str) -> crate::Brep {
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
match name {
|
||||
"box" => ffi::make_box(&[1.0, -2.0, 3.0], &[10.0, 20.0, 30.0]),
|
||||
"cylinder" => ffi::make_cylinder(&[1.0, 2.0, -3.0], 5.0, 12.0),
|
||||
"sphere" => ffi::make_sphere(&[1.0, 2.0, 3.0], 7.0),
|
||||
"cone" => ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 0.0, 9.0),
|
||||
"frustum" => ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 2.0, 9.0),
|
||||
"torus" => ffi::make_torus(&[1.0, -1.0, 0.5], 10.0, 3.0),
|
||||
_ => panic!("{name}"),
|
||||
}
|
||||
.map(Brep::made)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `x` within `TOL` of the C++ kernel's `y`, relative to `scale`.
|
||||
#[cfg(feature = "native")]
|
||||
fn same(x: f64, y: f64, scale: f64, what: &str) {
|
||||
assert!((x - y).abs() <= TOL * scale, "{what}: solidcore {x} vs C++ {y}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn mass_properties_parity(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let ours = mass_properties(&b, MEASURE_ACCURACY).unwrap();
|
||||
let theirs = cpp(name).mass_properties(MEASURE_ACCURACY).unwrap();
|
||||
same(ours.area, theirs.area, theirs.area, &format!("{name} area"));
|
||||
same(ours.volume, theirs.volume, theirs.volume, &format!("{name} volume"));
|
||||
let imax = theirs.moments.iter().fold(0.0f64, |a, &b| a.max(b));
|
||||
for i in 0..3 {
|
||||
same(ours.centroid[i], theirs.centroid[i], size(&c), &format!("{name} centroid[{i}]"));
|
||||
same(ours.moments[i], theirs.moments[i], theirs.moments[i], &format!("{name} moments[{i}]"));
|
||||
same(ours.products[i], theirs.products[i], imax, &format!("{name} products[{i}]"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn area_parity(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
let theirs = cpp(name).area(MEASURE_ACCURACY).unwrap();
|
||||
same(area(&b, MEASURE_ACCURACY).unwrap(), theirs, theirs, &format!("{name} area"));
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn bounds_parity(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let (lo, hi) = bounds(&b).unwrap();
|
||||
let (tlo, thi) = cpp(name).bounds().unwrap();
|
||||
for i in 0..3 {
|
||||
same(lo[i], tlo[i], size(&c), &format!("{name} low[{i}]"));
|
||||
same(hi[i], thi[i], size(&c), &format!("{name} high[{i}]"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn solid_count_parity(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
assert_eq!(solid_count(&b).unwrap(), cpp(name).solid_count().unwrap(), "{name}");
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn manifold_parity(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
assert_eq!(is_manifold_solid(&b).unwrap(), cpp(name).is_manifold_solid().unwrap(), "{name}");
|
||||
}
|
||||
|
||||
/// The whole edge-by-face distance matrix, the C++ kernel's own (both bodies list their edges
|
||||
/// and faces in the same order: `convert_tests` shows their arrays are equal).
|
||||
#[cfg(feature = "native")]
|
||||
fn distance_parity(name: &str) {
|
||||
let (b, c) = case(name);
|
||||
let theirs = cpp(name);
|
||||
let [faces, edges, _] = counts(&b).unwrap();
|
||||
for e in 0..edges {
|
||||
for f in 0..faces {
|
||||
let (x, y) = (edge_face_distance(&b, e, f), theirs.edge_face_distance(e, f));
|
||||
match (x, y) {
|
||||
(Ok(x), Ok(y)) => same(x, y, size(&c), &format!("{name} edge {e} face {f}")),
|
||||
(x, y) => assert_eq!(x.is_ok(), y.is_ok(), "{name} edge {e} face {f}: {x:?} vs {y:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn check_parity(name: &str) {
|
||||
let (b, _) = case(name);
|
||||
for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] {
|
||||
assert_eq!(check(&b, level).unwrap(), cpp(name).check(level).unwrap(), "{name}, {level:?}");
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! primitive {
|
||||
($m:ident, $name:literal $(, mass: $mass:meta)? $(, area: $area:meta)?) => {
|
||||
mod $m {
|
||||
use super::*;
|
||||
#[test]
|
||||
$(#[$mass])?
|
||||
fn mass_properties_match_the_closed_forms() {
|
||||
mass_properties_match($name);
|
||||
}
|
||||
#[test]
|
||||
$(#[$area])?
|
||||
fn area_matches_the_closed_form() {
|
||||
area_matches($name);
|
||||
}
|
||||
#[test]
|
||||
fn bounds_match_the_closed_form() {
|
||||
bounds_match($name);
|
||||
}
|
||||
#[test]
|
||||
fn is_one_solid() {
|
||||
one_solid($name);
|
||||
}
|
||||
#[test]
|
||||
fn is_a_manifold_solid() {
|
||||
manifold_solid($name);
|
||||
}
|
||||
#[test]
|
||||
fn edges_lie_on_their_faces() {
|
||||
super::edges_lie_on_their_faces($name);
|
||||
}
|
||||
#[test]
|
||||
fn checks_valid() {
|
||||
super::checks_valid($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$mass])?
|
||||
fn mass_properties_match_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
mass_properties_parity($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$area])?
|
||||
fn area_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
area_parity($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn bounds_match_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
bounds_parity($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn solid_count_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
solid_count_parity($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn manifold_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
manifold_parity($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn distances_match_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
distance_parity($name);
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn check_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
check_parity($name);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
primitive!(box_, "box");
|
||||
primitive!(cylinder, "cylinder");
|
||||
primitive!(sphere, "sphere");
|
||||
primitive!(cone, "cone");
|
||||
primitive!(frustum, "frustum");
|
||||
primitive!(torus, "torus");
|
||||
|
||||
/// The head of each report line, `<reporting type>::Test #<n>`, or the whole line for the capi's
|
||||
/// own lines (a failed status without a report, a skipped AssertValid).
|
||||
#[cfg(feature = "native")]
|
||||
fn tests_of(text: &str) -> Vec<&str> {
|
||||
text.lines().map(|l| l.find(" #").and_then(|i| l[i + 2..].find(' ').map(|j| &l[..i + 2 + j])).unwrap_or(l)).collect()
|
||||
}
|
||||
|
||||
/// A valid box passes every test at level 2 (SolidCraft's level), with an empty report, on
|
||||
/// solidcore and on the C++ kernel.
|
||||
#[test]
|
||||
fn a_valid_box_passes_level_2() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (b, _) = case("box");
|
||||
let r = check(&b, CheckLevel::All).unwrap();
|
||||
assert_eq!((r.failures, r.text.as_str()), (0, ""), "{r}");
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
let r = cpp("box").check(CheckLevel::All).unwrap();
|
||||
assert_eq!((r.failures, r.text.as_str()), (0, ""), "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A box with one corner moved 1 mm off its edges, through the arrays: the check finds it broken,
|
||||
/// and the C++ kernel fails the same tests on the same arrays.
|
||||
#[test]
|
||||
fn a_vertex_off_its_edges_fails_the_check() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::flat::VERTEX_POSITION;
|
||||
let (b, _) = case("box");
|
||||
let mut a = super::to_arrays(&b).unwrap();
|
||||
a.reals.get_mut(&VERTEX_POSITION).unwrap()[0] += 1.0;
|
||||
let broken = super::from_arrays(&a).unwrap();
|
||||
let r = check(&broken, CheckLevel::All).unwrap();
|
||||
assert!(!r.is_valid(), "{r}");
|
||||
assert_eq!(r.failures, r.text.lines().filter(|l| l.contains("::Test #")).count(), "{r}");
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
let cpp = crate::flat::Flat::from_arrays(&a).unwrap().import().unwrap();
|
||||
let c = cpp.check(CheckLevel::All).unwrap();
|
||||
assert_eq!(tests_of(&r.text), tests_of(&c.text), "solidcore:\n{}\nC++:\n{}", r.text, c.text);
|
||||
assert_eq!(r, c);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_numbers_are_refused() {
|
||||
let (b, _) = case("box");
|
||||
assert_eq!(mass_properties(&b, f64::NAN).unwrap_err(), crate::Error::Argument);
|
||||
assert_eq!(area(&b, f64::INFINITY).unwrap_err(), crate::Error::Argument);
|
||||
assert_eq!(edge_face_distance(&b, usize::MAX, 0).unwrap_err(), crate::Error::Argument);
|
||||
// Out of range is clamped, not refused.
|
||||
assert!(rel(mass_properties(&b, 1e-30).unwrap().volume, 6000.0) < TOL);
|
||||
assert!(rel(mass_properties(&b, 10.0).unwrap().volume, 6000.0) < TOL);
|
||||
}
|
||||
|
||||
/// A 20 mm box minus a centred 10 mm box (a sealed void), as solidcore's boolean makes it and as
|
||||
/// it comes back from arrays listing its solid region's shells inner first.
|
||||
fn sealed_voids() -> [(&'static str, Body); 2] {
|
||||
use crate::BooleanOp;
|
||||
use crate::flat::{FACEUSE_FACE, FACEUSE_ORIENTATION, REGION_SHELL_COUNT, SHELL_FACEUSE_COUNT};
|
||||
let body = super::boolean::boolean(&make_box([-10.0; 3], [20.0; 3]).unwrap(), &make_box([-5.0; 3], [10.0; 3]).unwrap(), BooleanOp::Subtract)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
// The bounded solid region of two shells: swap their blocks (the shell counts match, as do
|
||||
// the face-use counts, six faces each), so only the face uses move.
|
||||
let mut a = super::to_arrays(&body).unwrap();
|
||||
let regions = &a.ints[®ION_SHELL_COUNT];
|
||||
let first = regions.iter().position(|&n| n == 2).unwrap();
|
||||
let shell: usize = regions[..first].iter().map(|&n| usize::try_from(n).unwrap()).sum();
|
||||
let counts = &a.ints[&SHELL_FACEUSE_COUNT];
|
||||
assert_eq!((counts[shell], counts[shell + 1]), (6, 6));
|
||||
let fu: usize = counts[..shell].iter().map(|&n| usize::try_from(n).unwrap()).sum();
|
||||
for id in [FACEUSE_FACE, FACEUSE_ORIENTATION] {
|
||||
let v = a.ints.get_mut(&id).unwrap();
|
||||
let (x, y) = v[fu..fu + 12].split_at_mut(6);
|
||||
x.swap_with_slice(y);
|
||||
}
|
||||
let reordered = super::from_arrays(&a).unwrap();
|
||||
[("solidcore's", body), ("inner first", reordered)]
|
||||
}
|
||||
|
||||
/// The sealed void's mass properties: 8000 − 1000 to 1e-9, centroid at the origin, one solid,
|
||||
/// in either shell order; with SMLib built, the C++ kernel's own.
|
||||
#[test]
|
||||
fn a_sealed_void_measures_its_volume() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let want = 8000.0 - 1000.0;
|
||||
// Ixx of a cube of side s about its centre: s⁵ / 6.
|
||||
let moment = (20.0f64.powi(5) - 10.0f64.powi(5)) / 6.0;
|
||||
#[cfg(feature = "native")]
|
||||
let theirs = {
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
let make = |o: f64, s: f64| ffi::make_box(&[o; 3], &[s; 3]).map(Brep::made).unwrap();
|
||||
let (outer, inner) = (make(-10.0, 20.0), make(-5.0, 10.0));
|
||||
let h = ffi::ops::boolean(outer.cpp().unwrap(), inner.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap();
|
||||
ffi::volume(&h, 1e-9).unwrap()
|
||||
};
|
||||
for (at, b) in sealed_voids() {
|
||||
let m = mass_properties(&b, MEASURE_ACCURACY).unwrap();
|
||||
assert!(rel(m.volume, want) < TOL, "{at}: volume {}", m.volume);
|
||||
assert!(m.centroid.iter().all(|c| c.abs() < TOL * 20.0), "{at}: centroid {:?}", m.centroid);
|
||||
assert!(m.moments.iter().all(|&i| rel(i, moment) < TOL), "{at}: moments {:?} vs {moment}", m.moments);
|
||||
assert_eq!(solid_count(&b).unwrap(), 1, "{at}");
|
||||
#[cfg(feature = "native")]
|
||||
same(m.volume, theirs, theirs, &format!("{at} volume"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Two 10 mm squares apart in z = 0, one body of two faces with a shell each (as the top and
|
||||
/// front of a box offset apart across their edge make, kernel case
|
||||
/// `surfaces/cov-offset-two-faces-across-a-sharp-edge-come-apart`): `SmBrep` test 21,
|
||||
/// "Suspicious Shell Count", warns of it, and the capi's check counts no warning.
|
||||
#[test]
|
||||
fn faces_apart_with_a_shell_each_check_valid() {
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::primitive::primitive_creation::p1::create_rectangle;
|
||||
|
||||
use super::boolean::boolean2d;
|
||||
use super::run;
|
||||
use crate::BooleanOp;
|
||||
|
||||
let square = |x: f64| {
|
||||
let frame = Axis2Placement::new(Vec3::new(x, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
|
||||
run("create_rectangle", |k| create_rectangle(k, 0.0, 10.0, 10.0, &frame)).map(Body::new).unwrap()
|
||||
};
|
||||
let b = boolean2d(&square(0.0), &square(20.0), BooleanOp::Union).unwrap().unwrap();
|
||||
assert_eq!(counts(&b).unwrap()[0], 2);
|
||||
for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] {
|
||||
let r = check(&b, level).unwrap();
|
||||
assert!(r.is_valid(), "{level:?}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `smlib_brep_classify_point` (fork 34d6b2b), the capi test's cases: a 10 x 20 x 30 box at
|
||||
/// `(1, -2, 3)`: inside, outside, on a face, an edge and a vertex; the cylinder of radius 5 about
|
||||
/// the z axis through `(1, 2)`: off its curved wall by 1e-8 on it (within the zone tolerance), by
|
||||
/// 1e-4 inside or outside, on its axis inside, on its top's centre on it. With SMLib built, the
|
||||
/// C++ kernel's own primitives classify the same.
|
||||
#[test]
|
||||
fn points_classify_as_the_capi() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use super::classify_point;
|
||||
use crate::measure::PointClass::{Inside, OnBoundary, Outside};
|
||||
|
||||
let (bx, _) = case("box");
|
||||
let (cyl, _) = case("cylinder");
|
||||
let c45 = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let wall = |r: f64| [1.0 + r * c45, 2.0 + r * c45, 3.0];
|
||||
let cases = [
|
||||
("box", [6.0, 8.0, 18.0], Inside),
|
||||
("box", [6.0, 8.0, 34.0], Outside),
|
||||
("box", [0.0, -3.0, 2.0], Outside),
|
||||
("box", [6.0, 8.0, 33.0], OnBoundary),
|
||||
("box", [11.0, 8.0, 33.0], OnBoundary),
|
||||
("box", [11.0, 18.0, 33.0], OnBoundary),
|
||||
("box", [1.0, -2.0, 3.0], OnBoundary),
|
||||
("cylinder", wall(5.0 + 1e-8), OnBoundary),
|
||||
("cylinder", wall(5.0 - 1e-8), OnBoundary),
|
||||
("cylinder", wall(5.0 + 1e-4), Outside),
|
||||
("cylinder", wall(5.0 - 1e-4), Inside),
|
||||
("cylinder", [1.0, 2.0, 3.0], Inside),
|
||||
("cylinder", [1.0, 2.0, 9.0], OnBoundary),
|
||||
];
|
||||
for (name, p, want) in cases {
|
||||
let body = if name == "box" { &bx } else { &cyl };
|
||||
assert_eq!(classify_point(body, p).unwrap(), want, "{name} {p:?}");
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
use crate::ffi::measure::{POINT_INSIDE, POINT_ON_BOUNDARY, POINT_OUTSIDE, classify_point as cpp_classify};
|
||||
let theirs = cpp_classify(cpp(name).cpp().unwrap(), p).unwrap();
|
||||
let want_cpp = match want {
|
||||
Inside => POINT_INSIDE,
|
||||
Outside => POINT_OUTSIDE,
|
||||
OnBoundary => POINT_ON_BOUNDARY,
|
||||
};
|
||||
assert_eq!(theirs, want_cpp, "C++ {name} {p:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(classify_point(&bx, [f64::NAN, 0.0, 0.0]).unwrap_err(), crate::Error::Argument);
|
||||
assert_eq!(classify_point(&bx, [0.0, f64::INFINITY, 0.0]).unwrap_err(), crate::Error::Argument);
|
||||
}
|
||||
|
||||
/// A sheet hung on a solid (fork 9767916's capi test): a 10 mm cube and a 10 x 10 sheet on from its
|
||||
/// top front edge, merged into one body. Its area is every face's, the cube's 600 and the sheet's
|
||||
/// 100, not the cube's boundary alone; its volume the cube's. The same on the C++ kernel.
|
||||
#[test]
|
||||
fn a_sheet_hung_on_a_solid_counts_in_the_area() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use solidcore::boolean::api_brep::boolean_merge;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::primitive::api_primitives::{create_box, create_plane};
|
||||
let both = super::run("cube and flap", |k| {
|
||||
let cube = create_box(k, Vec3::new(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?;
|
||||
let flap = create_plane(k, Vec3::new(10.0, 0.0, 10.0), 10.0, 10.0)?;
|
||||
boolean_merge(k, cube, flap)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap();
|
||||
let m = mass_properties(&both, 1e-8).unwrap();
|
||||
assert!((m.area - 700.0).abs() < 1e-6 && (m.volume - 1000.0).abs() < 1e-6, "{m:?}");
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
let cpp = crate::Brep::made_core(both);
|
||||
let c = crate::ffi::measure::mass_properties(cpp.cpp().unwrap(), 1e-8).unwrap();
|
||||
assert!((c[0] - m.area).abs() < 1e-6 && (c[1] - m.volume).abs() < 1e-6, "C++ {c:?} vs {m:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Sewing and merging on solidcore: the C API's `smlib_stitch` (the fork's
|
||||
//! `capi/src/smlib_sheets.cpp`) and `smlib_merge_faces` (`capi/src/smlib_ops.cpp`, with
|
||||
//! `capi/src/smlib_pick.h`'s `pick_edges`).
|
||||
//!
|
||||
//! As there, each works on a copy of its body (`SmApiBrepCopy`). A stitch is `SmBrep::StitchFaces`
|
||||
//! at the caller's tolerance, once, then `SmBrep::OrientTrimmedSurfaces`: outward where the faces
|
||||
//! close into a solid, else only made to agree across their shared edges. `StitchAndOrient` is not
|
||||
//! used, as the capi does not use it: it caps the tolerance and stitches a second time, so the
|
||||
//! caller's tolerance would not be the one that decides.
|
||||
//!
|
||||
//! `StitchFaces` glues an edge only to an edge one face uses: a sheet laid on edges two faces of
|
||||
//! a solid already share (a cover over a box's cut top) is left beside the solid. As the fork's
|
||||
//! e5d9bba, edges still lamina after that pass are glued at the same tolerance to the edges they
|
||||
//! lie on whatever uses those (`bAllowSpineEdges`), on a copy ([`close_onto_shared_edges`]).
|
||||
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::topology::brep::p1 as bl;
|
||||
use solidcore::topology::brep::p2::{ArenaNotifyState, is_manifold_solid};
|
||||
use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face, orient_trimmed_surfaces, with_kernel_callbacks};
|
||||
use solidcore::topology::brep::p4::stitch_faces;
|
||||
use solidcore::topology::brep::p5::remove_topological_edges_and_vertices;
|
||||
use solidcore::topology::face::p2 as face_p2;
|
||||
use solidcore::topology::topology_traverser::Marks;
|
||||
use solidcore::topology::{Brep, FaceId, RegionId, edge};
|
||||
|
||||
use super::Body;
|
||||
use super::blend::{edge_miss, finish, on_copy, pick_edges, points_ok, status, tolerance_ok};
|
||||
use crate::{Error, Result};
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
|
||||
type Out<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// `smlib_stitch`: the faces of a copy of `body` sewn along the vertices and edges that coincide
|
||||
/// within `tolerance` (finite, positive, at most 1e3), then oriented; with whether the result is a
|
||||
/// closed solid (no lamina edge left and `IsManifoldSolid`).
|
||||
pub(crate) fn stitch(body: &Body, tolerance: f64) -> Result<(Body, bool)> {
|
||||
if !tolerance.is_finite() || !(tolerance > 0.0) || tolerance > 1e3 {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let mut closed = false;
|
||||
let r = on_copy("SmBrep::StitchFaces", body, |k, mut b| {
|
||||
let mut lamina = match stitch_faces(k, &mut b, tolerance, None, false) {
|
||||
Ok(o) => o.lamina_edges,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
if lamina != 0 {
|
||||
b = match close_onto_shared_edges(k, b, tolerance, &mut lamina)? {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
}
|
||||
let oriented = with_kernel_callbacks(k, &mut b, |k, b, env| {
|
||||
let mut marks = Marks::default();
|
||||
orient_trimmed_surfaces(k, b, env, &mut marks, true, lamina != 0, None)
|
||||
});
|
||||
if let Err(e) = oriented {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
b.refresh_cached_topology();
|
||||
closed = lamina == 0 && is_manifold_solid(&b)?;
|
||||
Ok(Ok(b))
|
||||
})?;
|
||||
Ok((r, closed))
|
||||
}
|
||||
|
||||
/// How many regions of `b` are material: neither the infinite region nor a void (`IsVoid`'s
|
||||
/// `UNSURE` counts as void, as the capi's `!IsVoid()`).
|
||||
fn material_regions(b: &Brep) -> solidcore::Result<usize> {
|
||||
let infinite = b.infinite_region();
|
||||
let mut n = 0;
|
||||
for r in bl::regions(b, None)? {
|
||||
n += usize::from(Some(r) != infinite && b.region(r)?.is_void() == 0);
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// The region on the side of `f`'s faceuse `fu` (its shell's), if any.
|
||||
fn region_of(b: &Brep, fu: Option<solidcore::topology::FaceuseId>) -> solidcore::Result<Option<RegionId>> {
|
||||
let Some(fu) = fu else { return Ok(None) };
|
||||
match b.faceuse(fu)?.shell {
|
||||
Some(sh) => Ok(b.shell(sh)?.region),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `remove_faces_inside`: every face of `b` lying between two material regions is inside the
|
||||
/// part and removed (`DeleteFace(f, TRUE, TRUE)` with the B-rep open for editing): its regions
|
||||
/// are joined, and its edges and vertices nothing else uses go with it.
|
||||
fn remove_faces_inside(k: &mut Kernel, b: &mut Brep) -> solidcore::Result<Out<()>> {
|
||||
let infinite = b.infinite_region();
|
||||
let mut inside: Vec<FaceId> = Vec::new();
|
||||
for f in bl::faces(b, None)? {
|
||||
let (u1, u2) = face_p2::faceuses(b, f)?;
|
||||
let (Some(r1), Some(r2)) = (region_of(b, u1)?, region_of(b, u2)?) else { continue };
|
||||
let material = |r: RegionId| -> solidcore::Result<bool> { Ok(Some(r) != infinite && b.region(r)?.is_void() == 0) };
|
||||
if r1 != r2 && material(r1)? && material(r2)? {
|
||||
inside.push(f);
|
||||
}
|
||||
}
|
||||
let editing = b.editing_enabled();
|
||||
b.set_editing_enabled(true);
|
||||
let mut deleted = Ok(());
|
||||
{
|
||||
let mut state = ArenaNotifyState::new(b);
|
||||
let mut env = state.env(None);
|
||||
let mut marks = Marks::default();
|
||||
for f in inside {
|
||||
let options = DeleteFaceOptions { delete_edges_vertices: true, do_region_nesting: true, ..DeleteFaceOptions::default() };
|
||||
if let Err(e) = delete_face(k, b, &mut env, &mut marks, f, options) {
|
||||
deleted = Err(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
b.set_editing_enabled(editing);
|
||||
b.refresh_cached_topology();
|
||||
Ok(deleted.map_err(status))
|
||||
}
|
||||
|
||||
/// `close_onto_shared_edges` (fork e5d9bba): `b`, stitched as a manifold with `lamina` edges
|
||||
/// left free, those glued at `tolerance` to the edges they lie on whatever uses those
|
||||
/// (`StitchFaces` with `bAllowSpineEdges`) on a copy. The copy is taken, with its count of
|
||||
/// lamina edges, when the gluing closed a space (a new, material region) and, the faces then
|
||||
/// between two material regions removed, no edge has more than two faces; a sheet that closes
|
||||
/// nothing with the solid's faces stays as the manifold stitch left it. A gluing that leaves
|
||||
/// fewer material regions than it found (a sheet with no sector between the faces at its rim:
|
||||
/// glued elsewhere it joins the solid's inside to the outside) is refused, as the capi's
|
||||
/// `SMLIB_ERR_UNSUPPORTED`.
|
||||
fn close_onto_shared_edges(k: &mut Kernel, b: Brep, tolerance: f64, lamina: &mut u32) -> solidcore::Result<Out<Brep>> {
|
||||
let mut c = brep_copy(k, &b)?;
|
||||
let material = material_regions(&c)?;
|
||||
let outcome = match stitch_faces(k, &mut c, tolerance, None, true) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
if outcome.stitched_edges == 0 {
|
||||
return Ok(Ok(b));
|
||||
}
|
||||
if material_regions(&c)? < material {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
if let Err(e) = remove_faces_inside(k, &mut c)? {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
for e in bl::edges(&c, None)? {
|
||||
if edge::is_spine(&c, e)? {
|
||||
return Ok(Ok(b));
|
||||
}
|
||||
}
|
||||
*lamina = outcome.lamina_edges;
|
||||
Ok(Ok(c))
|
||||
}
|
||||
|
||||
/// `smlib_merge_faces`: `SmBrep::RemoveTopologicalEdgesAndVertices` on a copy of `body`, keeping
|
||||
/// the edges nearest `keep` (each within `tolerance`); then `finish` (faces left, and a solid in
|
||||
/// gives a manifold solid).
|
||||
pub(crate) fn merge_faces(body: &Body, keep: &[[f64; 3]], tolerance: f64) -> Result<Body> {
|
||||
if !points_ok(keep, true) || !tolerance_ok(tolerance) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("SmBrep::RemoveTopologicalEdgesAndVertices", body, |k, mut b| {
|
||||
let kept = if keep.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
match pick_edges(k, &b, keep, tolerance)? {
|
||||
Ok((edges, _)) => edges,
|
||||
Err(m) => return Ok(Err(edge_miss(m))),
|
||||
}
|
||||
};
|
||||
let solid_in = is_manifold_solid(&b)?;
|
||||
let keep_edges = (!kept.is_empty()).then_some(kept.as_slice());
|
||||
if let Err(e) = remove_topological_edges_and_vertices(k, &mut b, keep_edges, None) {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
b.refresh_cached_topology();
|
||||
finish(b, solid_in)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! Stitch and merge faces on solidcore, against closed forms: counts, volumes (or areas) and
|
||||
//! whether the result closes, and every result exports to a record that validates. With SMLib
|
||||
//! built, the same input converted to the C++ kernel gives the same result or the same error
|
||||
//! (parity only: the expected values are the analytic ones).
|
||||
//!
|
||||
//! A stitch's input is six (or five) separate planar faces of a 10 mm cube, each its own face of
|
||||
//! one body with its own edges, none shared; a merge's is a cube with four faces cut across, or
|
||||
//! two boxes unioned, on solidcore.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::curve::line::Line;
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::vector2d::Vec2;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::nurbs::b_spline_curve::p1::KnotType;
|
||||
use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
|
||||
use solidcore::primitive::api_primitives::{create_box, create_plane};
|
||||
use solidcore::primitive::api_trim_ops::trim_surface_with_3d_curves;
|
||||
use solidcore::surface::plane::p1::Plane;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::topology::Brep;
|
||||
use solidcore::topology::brep::p2::ArenaNotifyState;
|
||||
use solidcore::topology::brep::p3::{merge_brep, new_brep};
|
||||
use solidcore::topology::brep::p8::create_face_from_surface;
|
||||
|
||||
use super::merge::{merge_faces, stitch};
|
||||
use super::sweep::build_faces;
|
||||
use super::{Body, area, bounds, counts, make_box, run, to_arrays, volume};
|
||||
use crate::export::native;
|
||||
use crate::{BooleanOp, Error};
|
||||
|
||||
const TOL: f64 = 0.01;
|
||||
|
||||
/// The sides of the cube [0, 10]^3 as (origin, u axis, v axis), each over [0, 10]^2.
|
||||
const SIDES: [([f64; 3], [f64; 3], [f64; 3]); 6] = [
|
||||
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||||
([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
([0.0, 10.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
|
||||
([0.0, 0.0, 10.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
|
||||
];
|
||||
|
||||
fn v3(p: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(p[0], p[1], p[2])
|
||||
}
|
||||
|
||||
/// The sides `sides` of the cube as separate faces of one body, the top lifted by `lift`.
|
||||
fn loose_faces(sides: &[usize], lift: f64) -> Body {
|
||||
run("loose faces", |k| {
|
||||
let mut b = new_brep(k, solidcore::geom::tol::USE_DEFAULT, solidcore::geom::tol::USE_DEFAULT)?;
|
||||
for &i in sides {
|
||||
let Some(&(mut o, u, v)) = SIDES.get(i) else { continue };
|
||||
if i == 5 {
|
||||
o[2] += lift;
|
||||
}
|
||||
let plane = Plane::new(v3(o), v3(u), v3(v), Vec2::new(1.0, 1.0), &Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0));
|
||||
let domain = Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0);
|
||||
build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(Surface::from(plane)), &domain, fenv))?;
|
||||
}
|
||||
b.refresh_cached_topology();
|
||||
Ok(b)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Two 10 mm cubes side by side along x, unioned: one 20 x 10 x 10 block.
|
||||
fn two_boxes() -> Body {
|
||||
let a = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let b = make_box([10.0, 0.0, 0.0], [10.0; 3]).unwrap();
|
||||
super::boolean::boolean(&a, &b, BooleanOp::Union).unwrap().unwrap()
|
||||
}
|
||||
|
||||
/// The cube with its four faces across x = 5 cut there (`split_faces`): ten faces.
|
||||
fn split_cube() -> Body {
|
||||
let plane = crate::ssi::Spec::Plane { origin: [5.0, 0.0, 0.0], xaxis: [0.0, 1.0, 0.0], yaxis: [0.0, 0.0, 1.0], u: [-1.0, 11.0], v: [-1.0, 11.0] };
|
||||
let cube = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
super::split::split_faces(&cube, &[0, 1, 2, 3, 4, 5], &[&plane]).unwrap().unwrap().0
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Input {
|
||||
SplitCube,
|
||||
/// [`loose_faces`]: which sides, the top's lift.
|
||||
Loose(&'static [usize], f64),
|
||||
TwoBoxes,
|
||||
Box,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn core(self) -> Body {
|
||||
match self {
|
||||
Input::Loose(sides, lift) => loose_faces(sides, lift),
|
||||
Input::TwoBoxes => two_boxes(),
|
||||
Input::SplitCube => split_cube(),
|
||||
Input::Box => make_box([0.0; 3], [10.0; 3]).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Op {
|
||||
Stitch(Input, f64),
|
||||
Merge(Input, &'static [[f64; 3]], f64),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Expect {
|
||||
/// A solid: volume, counts, closed.
|
||||
Solid(f64, [usize; 3], bool),
|
||||
/// A sheet: area, counts, closed.
|
||||
Sheet(f64, [usize; 3], bool),
|
||||
Fails(Error),
|
||||
}
|
||||
|
||||
const ALL: &[usize] = &[0, 1, 2, 3, 4, 5];
|
||||
const OPEN: &[usize] = &[0, 1, 2, 3, 4];
|
||||
|
||||
fn case(name: &str) -> (Op, Expect) {
|
||||
match name {
|
||||
"stitch_six_faces_into_a_cube" => (Op::Stitch(Input::Loose(ALL, 0.0), TOL), Expect::Solid(1000.0, [6, 12, 8], true)),
|
||||
"stitch_five_faces_into_an_open_box" => (Op::Stitch(Input::Loose(OPEN, 0.0), TOL), Expect::Sheet(500.0, [5, 12, 8], false)),
|
||||
// The top 0.05 mm above the walls: farther than the tolerance, so it stays apart.
|
||||
"stitch_leaves_a_gap_wider_than_the_tolerance" => (Op::Stitch(Input::Loose(ALL, 0.05), TOL), Expect::Sheet(600.0, [6, 16, 12], false)),
|
||||
// Within a 0.1 mm tolerance, the same gap closes.
|
||||
// (the top stays on its own plane, so the solid reaches up to it)
|
||||
"stitch_closes_a_gap_within_the_tolerance" => (Op::Stitch(Input::Loose(ALL, 0.05), 0.1), Expect::Solid(1005.0, [6, 12, 8], true)),
|
||||
"stitch_a_cube_already_sewn" => (Op::Stitch(Input::Box, TOL), Expect::Solid(1000.0, [6, 12, 8], true)),
|
||||
"merge_two_boxes_into_one_block" => (Op::Merge(Input::TwoBoxes, &[], TOL), Expect::Solid(2000.0, [6, 12, 8], false)),
|
||||
"merge_a_split_cube_into_one" => (Op::Merge(Input::SplitCube, &[], TOL), Expect::Solid(1000.0, [6, 12, 8], false)),
|
||||
"merge_a_box_changes_nothing" => (Op::Merge(Input::Box, &[], TOL), Expect::Solid(1000.0, [6, 12, 8], false)),
|
||||
"merge_keeps_a_picked_edge" => (Op::Merge(Input::SplitCube, &[[5.0, 5.0, 10.0]], TOL), Expect::Solid(1000.0, [7, 15, 10], false)),
|
||||
"merge_picks_near_nothing" => {
|
||||
(Op::Merge(Input::SplitCube, &[[5.0, 5.0, 10.0], [50.0, 50.0, 50.0]], TOL), Expect::Fails(Error::NoMatch { point: 1 }))
|
||||
}
|
||||
_ => panic!("no case {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Measured {
|
||||
size: f64,
|
||||
counts: [usize; 3],
|
||||
closed: bool,
|
||||
bounds: ([f64; 3], [f64; 3]),
|
||||
kinds: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
type Got = std::result::Result<Measured, Error>;
|
||||
|
||||
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for f in &rec.faces {
|
||||
let v = serde_json::to_value(&f.surface).unwrap();
|
||||
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
fn judge(name: &str, who: &str, got: Got) -> Got {
|
||||
let (op, expect) = case(name);
|
||||
match (&expect, &got) {
|
||||
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
|
||||
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
|
||||
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(Expect::Solid(s, n, closed) | Expect::Sheet(s, n, closed), Ok(m)) => {
|
||||
assert!(rel(m.size, *s) < 1e-9, "{name}: {who} size {} vs analytic {s}", m.size);
|
||||
assert_eq!(m.counts, *n, "{name}: {who} counts");
|
||||
if matches!(op, Op::Stitch(..)) {
|
||||
assert_eq!(m.closed, *closed, "{name}: {who} closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn is_sheet(name: &str) -> bool {
|
||||
matches!(case(name).1, Expect::Sheet(..))
|
||||
}
|
||||
|
||||
fn solidcore_case(name: &str) -> Got {
|
||||
let (op, _) = case(name);
|
||||
let r = match op {
|
||||
Op::Stitch(input, tol) => stitch(&input.core(), tol),
|
||||
Op::Merge(input, keep, tol) => merge_faces(&input.core(), keep, tol).map(|b| (b, false)),
|
||||
};
|
||||
let got = r.map(|(b, closed)| {
|
||||
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
let size = if is_sheet(name) { area(&b, 1e-8) } else { volume(&b, 1e-8) }.unwrap();
|
||||
Measured { size, counts: counts(&b).unwrap(), closed, bounds: bounds(&b).unwrap(), kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "solidcore", got)
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, called directly (whatever the choice), on the input converted
|
||||
/// from solidcore's.
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Got {
|
||||
use crate::ops::pick_error;
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let (op, _) = case(name);
|
||||
let r = match op {
|
||||
Op::Stitch(input, tol) => ffi::sheets::stitch(Brep::made_core(input.core()).cpp().unwrap(), tol).map_err(error),
|
||||
Op::Merge(input, keep, tol) => {
|
||||
ffi::ops::merge_faces(Brep::made_core(input.core()).cpp().unwrap(), keep, tol).map(|h| (h, false)).map_err(pick_error)
|
||||
}
|
||||
};
|
||||
let got = r.map(|(h, closed)| {
|
||||
let size = if is_sheet(name) { ffi::measure::area(&h, 1e-8) } else { ffi::volume(&h, 1e-8) }.unwrap();
|
||||
let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let bx = ffi::measure::bounds(&h).unwrap();
|
||||
let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Measured { size, counts: n, closed, bounds: bx, kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "C++", got)
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &str) {
|
||||
match (solidcore_case(name), cpp_case(name)) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
assert!(rel(a.size, b.size) < 1e-9, "{name}: solidcore {} vs C++ {}", a.size, b.size);
|
||||
assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)");
|
||||
assert_eq!(a.closed, b.closed, "{name}: closed (solidcore vs C++)");
|
||||
assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)");
|
||||
let near = |x: [f64; 3], y: [f64; 3]| x.iter().zip(&y).all(|(p, q)| (p - q).abs() < 1e-6);
|
||||
assert!(near(a.bounds.0, b.bounds.0) && near(a.bounds.1, b.bounds.1), "{name}: bounds {:?} vs C++ {:?}", a.bounds, b.bounds);
|
||||
}
|
||||
(a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::cpp_case(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
cases!(
|
||||
stitch_six_faces_into_a_cube,
|
||||
stitch_five_faces_into_an_open_box,
|
||||
stitch_leaves_a_gap_wider_than_the_tolerance,
|
||||
stitch_closes_a_gap_within_the_tolerance,
|
||||
stitch_a_cube_already_sewn,
|
||||
merge_two_boxes_into_one_block,
|
||||
merge_a_split_cube_into_one,
|
||||
merge_a_box_changes_nothing,
|
||||
merge_keeps_a_picked_edge,
|
||||
merge_picks_near_nothing,
|
||||
);
|
||||
|
||||
/// The capi's argument checks, before any kernel call.
|
||||
#[test]
|
||||
fn hostile_arguments_are_refused() {
|
||||
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
for t in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e3 + 1.0] {
|
||||
assert_eq!(stitch(&b, t).err(), Some(Error::Argument), "stitch tolerance {t}");
|
||||
}
|
||||
for t in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e6] {
|
||||
assert_eq!(merge_faces(&b, &[], t).err(), Some(Error::Argument), "merge tolerance {t}");
|
||||
}
|
||||
assert_eq!(merge_faces(&b, &[[f64::NAN, 0.0, 0.0]], TOL).err(), Some(Error::Argument));
|
||||
let many = vec![[0.0; 3]; crate::MAX_POINTS + 1];
|
||||
assert_eq!(merge_faces(&b, &many, TOL).err(), Some(Error::Argument));
|
||||
}
|
||||
|
||||
/// A 10 mm cube and, unsewn in the same body (`MergeBrep`), a sheet made on the kernel.
|
||||
fn cube_and_sheet(sheet: impl FnOnce(&mut Kernel) -> solidcore::Result<Brep>) -> Body {
|
||||
run("cube and sheet", |k| {
|
||||
let mut cube = create_box(k, Vec3::new(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?;
|
||||
let sheet = sheet(k)?;
|
||||
let mut state = ArenaNotifyState::new(&cube);
|
||||
let mut env = state.env(None);
|
||||
merge_brep(k, &mut env, &mut cube, &sheet, &solidcore::topology::brep::p2::copy_source_ref, None, None, true)?;
|
||||
cube.refresh_cached_topology();
|
||||
Ok(cube)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The capi's `cube_and_cover` (capi/tests/sheets.cpp): the cube and a cover over its top, a
|
||||
/// height field z(x, y) over [0, 10]^2 of `degree` and uniform `spans`, its control points at
|
||||
/// their Greville abscissae (so its parameters are x and y), z = 10 but for the middle one,
|
||||
/// 10 + `rise`, trimmed to the square by lines (`SmApiTrimSurfaceWith3dCurves`), so its rim is the
|
||||
/// cube's top edges.
|
||||
fn cube_and_cover(degree: usize, spans: usize, rise: f64) -> Body {
|
||||
cube_and_sheet(|k| {
|
||||
let n = spans + degree;
|
||||
let step = 10.0 / spans as f64;
|
||||
let unique: Vec<f64> = (0..=spans).map(|k| step * k as f64).collect();
|
||||
let mult: Vec<usize> = (0..=spans).map(|k| if k == 0 || k == spans { degree + 1 } else { 1 }).collect();
|
||||
let knots: Vec<f64> = unique.iter().zip(&mult).flat_map(|(&u, &m)| std::iter::repeat_n(u, m)).collect();
|
||||
let greville: Vec<f64> = (0..n).map(|i| knots[i + 1..=i + degree].iter().sum::<f64>() / degree as f64).collect();
|
||||
let mut net = Vec::with_capacity(n * n);
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let z = if 2 * i + 1 == n && 2 * j + 1 == n { 10.0 + rise } else { 10.0 };
|
||||
net.push(Vec3::new(greville[i], greville[j], z));
|
||||
}
|
||||
}
|
||||
let surface = BSplineSurface::create_canonical(&CanonicalSurfaceData {
|
||||
u_degree: degree,
|
||||
v_degree: degree,
|
||||
control_points: &net,
|
||||
form: BSplineSurfaceForm::Unspecified,
|
||||
u_multiplicities: &mult,
|
||||
v_multiplicities: &mult,
|
||||
u_knots: &unique,
|
||||
v_knots: &unique,
|
||||
knot_type: KnotType::Unspecified,
|
||||
weights: None,
|
||||
uv_domain: None,
|
||||
})?;
|
||||
let corner = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]].map(|[x, y]| Vec3::new(x, y, 10.0));
|
||||
let mut sides = Vec::with_capacity(4);
|
||||
for i in 0..4 {
|
||||
sides.push(Curve::from(Line::create_line_segment(3, corner[i], corner[(i + 1) % 4], None)?));
|
||||
}
|
||||
trim_surface_with_3d_curves(k, Surface::from(surface), &[4], sides)
|
||||
})
|
||||
}
|
||||
|
||||
/// A biquadratic Bezier cover rising 9 at its middle control point,
|
||||
/// z = 10 + 9 * 2s(1-s) * 2t(1-t) (s = x/10, t = y/10): the capi's cover.
|
||||
fn cube_and_biquadratic_cover() -> Body {
|
||||
cube_and_cover(2, 1, 9.0)
|
||||
}
|
||||
|
||||
/// A bicubic cover over 8 spans whose only raised control point is the middle one: the capi's
|
||||
/// cover lying on the cube's top for 2.5 mm in from every edge.
|
||||
fn cube_and_bicubic_cover() -> Body {
|
||||
cube_and_cover(3, 8, 9.0)
|
||||
}
|
||||
|
||||
/// The capi's flap: a 10 x 10 sheet on from the cube's top edge at x = 10, in the plane z = 10.
|
||||
fn cube_and_flap() -> Body {
|
||||
cube_and_sheet(|k| create_plane(k, Vec3::new(10.0, 0.0, 10.0), 10.0, 10.0))
|
||||
}
|
||||
|
||||
/// The space between the cover and the cube's top encloses 9 * (10/3)^2 = 100 mm^3: sewn, it is
|
||||
/// part of the solid and the top, now inside it, goes. A closed solid of the cube's five other
|
||||
/// faces and the cover, 1100 mm^3.
|
||||
#[test]
|
||||
fn a_cover_sews_onto_a_cube_into_a_closed_solid() {
|
||||
let (sewn, closed) = stitch(&cube_and_biquadratic_cover(), 1e-6).unwrap();
|
||||
assert!(closed);
|
||||
assert_eq!(counts(&sewn).unwrap()[0], 6);
|
||||
let v = volume(&sewn, 1e-8).unwrap();
|
||||
assert!((v - 1100.0).abs() < 1e-6, "volume {v}");
|
||||
}
|
||||
|
||||
/// The bicubic cover overlaps the top: no sector between the faces at the rim to sew it into, so
|
||||
/// it is refused, not sewn so that the cube's inside joins the outside.
|
||||
#[test]
|
||||
fn a_cover_lying_on_the_cube_is_refused() {
|
||||
assert_eq!(stitch(&cube_and_bicubic_cover(), 1e-6).err(), Some(Error::Unsupported));
|
||||
}
|
||||
|
||||
/// The flap closes nothing with the cube: it stays the sheet beside it the manifold stitch
|
||||
/// leaves, not glued into an edge three faces would share.
|
||||
#[test]
|
||||
fn a_flap_on_a_cube_edge_stays_beside_it() {
|
||||
let (sewn, closed) = stitch(&cube_and_flap(), 1e-6).unwrap();
|
||||
assert!(!closed);
|
||||
assert_eq!(counts(&sewn).unwrap()[0], 7);
|
||||
let (v, a) = (volume(&sewn, 1e-8).unwrap(), area(&sewn, 1e-8).unwrap());
|
||||
assert!((v - 1000.0).abs() < 1e-6 && (a - 700.0).abs() < 1e-6, "volume {v}, area {a}");
|
||||
}
|
||||
|
||||
/// The same three on C++, the same input converted: the fork's e5d9bba, as its capi tests say.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn sheets_on_a_cube_stitch_the_same_on_cpp() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let sew = |b: Body| ffi::sheets::stitch(Brep::made_core(b).cpp().unwrap(), 1e-6).map_err(error);
|
||||
let (cover, closed) = sew(cube_and_biquadratic_cover()).unwrap();
|
||||
assert!(closed);
|
||||
assert_eq!(ffi::counts(&cover).unwrap()[0], 6);
|
||||
assert!((ffi::volume(&cover, 1e-8).unwrap() - 1100.0).abs() < 1e-6);
|
||||
assert_eq!(sew(cube_and_bicubic_cover()).err(), Some(Error::Unsupported));
|
||||
let (flap, closed) = sew(cube_and_flap()).unwrap();
|
||||
assert!(!closed);
|
||||
assert_eq!(ffi::counts(&flap).unwrap()[0], 7);
|
||||
assert!((ffi::volume(&flap, 1e-8).unwrap() - 1000.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The inputs are never changed.
|
||||
#[test]
|
||||
fn the_body_stays_as_it_was() {
|
||||
let loose = loose_faces(ALL, 0.0);
|
||||
let _ = stitch(&loose, TOL).unwrap();
|
||||
assert_eq!(counts(&loose).unwrap(), [6, 24, 24]);
|
||||
let block = two_boxes();
|
||||
let was = counts(&block).unwrap();
|
||||
let _ = merge_faces(&block, &[], TOL).unwrap();
|
||||
assert_eq!(counts(&block).unwrap(), was);
|
||||
}
|
||||
|
||||
/// Face and edge tags (`Brep::tagged`) through a stitch and a merge on solidcore, as on
|
||||
/// C++: the input is tagged on solidcore, converted to C++ and tagged the same way there.
|
||||
#[cfg(feature = "native")]
|
||||
mod history {
|
||||
use crate::{Brep, ffi};
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_stitch() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::init().unwrap();
|
||||
let a = Brep::made_core(super::loose_faces(super::ALL, 0.0)).tagged(0).unwrap();
|
||||
let cpp = Brep::made(ffi::sheets::stitch(a.cpp().unwrap(), super::TOL).unwrap().0).history().unwrap();
|
||||
let core = Brep::made_core(super::stitch(a.core().unwrap(), super::TOL).unwrap().0).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_merge() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::init().unwrap();
|
||||
let a = Brep::made_core(super::split_cube()).tagged(0).unwrap();
|
||||
let cpp = Brep::made(ffi::ops::merge_faces(a.cpp().unwrap(), &[], super::TOL).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::merge_faces(a.core().unwrap(), &[], super::TOL).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
//! Shells, face offsets, thickening, sheet offsets and drafts on solidcore: the C API's logic
|
||||
//! round SMLib's offset executive, ported from the fork's `capi/src/smlib_ops.cpp`
|
||||
//! (`smlib_shell`, `smlib_offset_faces`, `smlib_thicken`, `smlib_draft`, `leaves_cavity`,
|
||||
//! `FaceSet`, `outer_faceuse`, `move_faces`, `draft_faces`), `capi/src/smlib_sheets.cpp`
|
||||
//! (`smlib_offset_sheet`) and `capi/src/smlib_pick.h` (`pick_faces`).
|
||||
//!
|
||||
//! As there, every operation works on a copy of its body (`SmApiBrepCopy`) and picks faces by
|
||||
//! point; the shell and the offsets are `SmApiShellBrepFull` and `SmApiOffsetBrepFull`, with
|
||||
//! convex edges extended and the results merged. A sheet offset takes faces that close into a
|
||||
//! solid as the capi does since tangency-labs/smlib#17.
|
||||
//!
|
||||
//! A draft tilts planes and, since tangency-labs/smlib#27, turns a cylinder about the pull into
|
||||
//! a cone (solidcore's `Cone::lean_cylinder`, solidcore#1545), and since smlib#53 a cone about the
|
||||
//! pull into the cone its generators turned make. A draft's tilted faces go in through solidcore's
|
||||
//! `SmBrep::LocalOperation` ([`local_operation`]). The capi's check that it moved some face
|
||||
//! (`moved_faces`) marks the replacement surfaces; solidcore names a surface by its owning face, so
|
||||
//! the mark goes on the picked faces, which it stays with through the operation (solidcore#1875).
|
||||
|
||||
use solidcore::EntityRef;
|
||||
use solidcore::SolidError;
|
||||
use solidcore::geom::attribute::{Attribute, Behavior, ids};
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::object::Shape;
|
||||
use solidcore::geom::vector3d::{Point3, Vec3};
|
||||
use solidcore::intersect::point_class::PointClassType;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::math::Point2;
|
||||
use solidcore::nurbs::b_spline_curve::ControlPointForm;
|
||||
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
|
||||
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
|
||||
use solidcore::nurbs::knots::Side;
|
||||
use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions, local_operation_simple, offset_brep_simple, shell_brep_simple};
|
||||
use solidcore::offset::offset_faces::{OffsetFacesExecutive, OffsetFacesRefusal};
|
||||
use solidcore::query::api_queries::{face_bounding_box, face_classify_uv, face_closest_point_in_shape, face_internal_point};
|
||||
use solidcore::query::brep::{BrepIdentity, BrepQuery, LiveBrepObjects};
|
||||
use solidcore::surface::cone::Cone;
|
||||
use solidcore::surface::surface::{Surface, SurfaceKind};
|
||||
use solidcore::topology::brep::{p1 as brep_p1, p2 as brep_p2};
|
||||
use solidcore::topology::face::p2 as face_p2;
|
||||
use solidcore::topology::{Brep, BrepId, FaceId, FaceuseId};
|
||||
|
||||
use super::blend::{Miss, on_copy, p3, pick, points_ok, status as blend_status, tolerance_ok};
|
||||
use super::{Body, answer};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The capi's statuses as SolidCraft errors: an operation's own result.
|
||||
type Out<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// `SM_ERR_INVALID_INPUT`.
|
||||
const INVALID_INPUT: i32 = 1007;
|
||||
|
||||
/// `SM_ERR_OFFSET_WALLS_TOUCH` (fork a759622), which `status` (smlib_shim.h) reports as
|
||||
/// `SMLIB_ERR_WALLS_TOUCH`.
|
||||
const OFFSET_WALLS_TOUCH: i32 = 1042;
|
||||
|
||||
/// `status` (smlib_shim.h): an offset whose walls touch is [`Error::WallsTouch`]; a vertex moved
|
||||
/// past an edge's other end (`SM_ERR_OFFSET_EDGE_VANISHES`, fork 34318f4) is
|
||||
/// `SMLIB_ERR_UNSUPPORTED`; any other status as the blends pass it on.
|
||||
fn status(e: SolidError) -> Error {
|
||||
match e {
|
||||
SolidError::Upstream { code: OFFSET_WALLS_TOUCH, .. } => Error::WallsTouch,
|
||||
e if e.is_offset_edge_vanishes() => Error::Unsupported,
|
||||
e => blend_status(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// `std::fabs(x) > 1e6` and the rest of the capi's size checks: finite, not zero, at most 1e6.
|
||||
fn distance_ok(x: f64) -> bool {
|
||||
x.is_finite() && x != 0.0 && x.abs() <= 1e6
|
||||
}
|
||||
|
||||
/// The B-rep id every shape and face key in this module names the one body by.
|
||||
const ONE: BrepId = BrepId::from_index(0);
|
||||
|
||||
/// Faces with their loose bounding boxes, for nearest-face queries (`FaceSet`).
|
||||
struct FaceSet {
|
||||
faces: Vec<FaceId>,
|
||||
boxes: Vec<(Point3, Point3)>,
|
||||
/// Each face's shape (`SmApiFaceClosestPoint` makes one per query; it depends only on the
|
||||
/// face, so it is made once).
|
||||
shapes: Vec<Shape>,
|
||||
}
|
||||
|
||||
impl FaceSet {
|
||||
fn new() -> FaceSet {
|
||||
FaceSet { faces: Vec::new(), boxes: Vec::new(), shapes: Vec::new() }
|
||||
}
|
||||
|
||||
/// `add`: false when the face's box cannot be measured.
|
||||
fn add(&mut self, k: &mut Kernel, b: &Brep, f: FaceId) -> solidcore::Result<bool> {
|
||||
let Some(bx) = answer(face_bounding_box(k, b, f, false))? else { return Ok(false) };
|
||||
let Some(shape) = answer(Shape::new(k, &(ONE, b), &[], &[(ONE, f)], &[], &[]))? else { return Ok(false) };
|
||||
self.faces.push(f);
|
||||
self.boxes.push((bx.min(), bx.max()));
|
||||
self.shapes.push(shape);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `nearest`: the distance from `p` to the nearest face, with the nearest point; infinite
|
||||
/// when none answers. A face whose box is farther than the nearest face so far is skipped.
|
||||
fn nearest(&self, k: &Kernel, live: &LiveBrepObjects<'_, '_>, p: Point3) -> solidcore::Result<(f64, Option<Point3>)> {
|
||||
let (mut best, mut at) = (f64::INFINITY, None);
|
||||
for ((&f, (lo, hi)), shape) in self.faces.iter().zip(&self.boxes).zip(&self.shapes) {
|
||||
let mut bx = 0.0;
|
||||
for (l, (h, x)) in [lo.x, lo.y, lo.z].into_iter().zip([hi.x, hi.y, hi.z].into_iter().zip([p.x, p.y, p.z])) {
|
||||
let out = (l - x).max((x - h).max(0.0));
|
||||
bx += out * out;
|
||||
}
|
||||
if f64::sqrt(bx) >= best {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = answer(face_closest_point_in_shape(k, shape, live, (ONE, f), p))?
|
||||
&& c.distance.is_finite()
|
||||
&& c.distance < best
|
||||
{
|
||||
best = c.distance;
|
||||
at = Some(c.point);
|
||||
}
|
||||
}
|
||||
Ok((best, at))
|
||||
}
|
||||
}
|
||||
|
||||
/// The live view of `b` the closest-point solves read, under `identity`: `query`'s effective
|
||||
/// identity ([`BrepQuery::effective_identity`]).
|
||||
fn live<'a, 'i>(b: &'a mut Brep, identity: &'a BrepIdentity<'i>) -> solidcore::Result<LiveBrepObjects<'a, 'i>> {
|
||||
LiveBrepObjects::new(b, identity, None)
|
||||
}
|
||||
|
||||
/// `pick_faces` (`face_distance`: `SmApiFaceClosestPoint`; a face whose query returns a status
|
||||
/// other than success is skipped, [`answer`]).
|
||||
pub(super) fn pick_faces(k: &mut Kernel, b: &mut Brep, points: &[[f64; 3]], tol: f64) -> solidcore::Result<std::result::Result<Vec<FaceId>, Miss>> {
|
||||
let all = brep_p1::faces(b, None)?;
|
||||
let model_tol = b.tolerance();
|
||||
let mut shapes = Vec::with_capacity(all.len());
|
||||
for &f in &all {
|
||||
shapes.push(answer(Shape::new(k, &(ONE, &*b), &[], &[(ONE, f)], &[], &[]))?);
|
||||
}
|
||||
let query = BrepQuery::of_brep(b);
|
||||
let identity = query.effective_identity();
|
||||
let objects = live(b, &identity)?;
|
||||
let k: &Kernel = k;
|
||||
let indices: Vec<usize> = (0..all.len()).collect();
|
||||
let picked = pick(&indices, points, tol, model_tol, |i, p| {
|
||||
let (Some(&f), Some(Some(shape))) = (all.get(i), shapes.get(i)) else { return Ok(None) };
|
||||
Ok(answer(face_closest_point_in_shape(k, shape, &objects, (ONE, f), p))?.map(|c| c.distance))
|
||||
})?;
|
||||
Ok(picked.map(|(p, _)| p.iter().filter_map(|&i| all.get(i).copied()).collect()))
|
||||
}
|
||||
|
||||
/// A face pick's failure as `pick_error` (ops.rs) reads it.
|
||||
pub(super) fn face_miss(m: Miss) -> Error {
|
||||
match m {
|
||||
Miss::None(point) => Error::NoMatch { point },
|
||||
Miss::Ambiguous(point) => Error::Ambiguous { point },
|
||||
}
|
||||
}
|
||||
|
||||
/// `outer_faceuse`: the faceuse of `f` on the outside of its body, the one bounding the infinite
|
||||
/// region (for a sheet, both do; then the upward one).
|
||||
fn outer_faceuse(b: &Brep, f: FaceId) -> solidcore::Result<Option<FaceuseId>> {
|
||||
let Some(fu) = face_p2::upward_faceuse(b, f)? else { return Ok(None) };
|
||||
let use_ = b.faceuse(fu)?;
|
||||
let outside = match use_.shell {
|
||||
Some(s) => b.shell(s)?.region.is_some() && b.shell(s)?.region == b.infinite_region(),
|
||||
None => false,
|
||||
};
|
||||
Ok(if outside { Some(fu) } else { use_.mate })
|
||||
}
|
||||
|
||||
/// `leaves_cavity`: whether an inward offset of `wall` (> 0) from every face of the solid `b`
|
||||
/// but `open` leaves a cavity: a point inside `b` farther than `wall`, and a margin, from every
|
||||
/// face that is offset. A point counts only as the centre of a ball inside `b`, reached from a
|
||||
/// face sample by stepping inward along the normal, then from ball to ball. The capi refuses a
|
||||
/// wall this finds no room for before SMLib sees it (C++ SMLib corrupts memory there,
|
||||
/// solidcore#380); the same refusal here keeps the C++ path's answer.
|
||||
fn leaves_cavity(k: &mut Kernel, b: &mut Brep, open: &[FaceId], wall: f64) -> solidcore::Result<bool> {
|
||||
let list = brep_p1::faces(b, None)?;
|
||||
let (mut all, mut walls) = (FaceSet::new(), FaceSet::new());
|
||||
for &f in &list {
|
||||
if !all.add(k, b, f)? {
|
||||
return Ok(false);
|
||||
}
|
||||
if !open.contains(&f) && !walls.add(k, b, f)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
if walls.faces.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
let tol = b.tolerance();
|
||||
let margin = (10.0 * tol).max(1e-3 * wall);
|
||||
let need = wall + margin;
|
||||
// Face samples: a grid over each offset face's domain, the points inside its trims, each
|
||||
// with the inward unit normal there.
|
||||
const GRID: usize = 5;
|
||||
const MAX_SAMPLES: usize = 2048;
|
||||
let mut samples: Vec<(Point3, Vec3)> = Vec::new();
|
||||
for &f in &walls.faces {
|
||||
let Some(fu) = outer_faceuse(b, f)? else { continue };
|
||||
let inward = if b.faceuse(fu)?.orientation == OrientType::Same { -1.0 } else { 1.0 };
|
||||
let dom = b.face(f)?.uv_domain;
|
||||
for i in 0..GRID {
|
||||
for j in 0..GRID {
|
||||
if samples.len() >= MAX_SAMPLES {
|
||||
break;
|
||||
}
|
||||
let uv: Point2 = dom.evaluate((i as f64 + 0.5) / GRID as f64, (j as f64 + 0.5) / GRID as f64);
|
||||
if answer(face_classify_uv(k, b, f, uv))? != Some(PointClassType::Face) {
|
||||
continue;
|
||||
}
|
||||
let Some(surf) = b.face(f)?.surface.as_ref() else { break };
|
||||
let (Some(at), Some(n)) = (answer(surf.evaluate_point(k, uv))?, answer(surf.evaluate_normal(k, uv, Side::Left, Side::Left))?) else {
|
||||
continue;
|
||||
};
|
||||
let len = n.length();
|
||||
if !(len > 0.0) || !len.is_finite() {
|
||||
continue;
|
||||
}
|
||||
samples.push((at, n * (inward / len)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let query = BrepQuery::of_brep(b);
|
||||
let identity = query.effective_identity();
|
||||
let objects = live(b, &identity)?;
|
||||
let k: &Kernel = k;
|
||||
// A step of `h` inward from a sample ends at a ball's centre when no face is nearer to it
|
||||
// than `h` (the step crossed none, and the ball touches only the sample's face).
|
||||
let ball = |s: Point3, n: Vec3, h: f64| -> solidcore::Result<(bool, Point3, f64)> {
|
||||
let c = s + n * h;
|
||||
let (r, _) = all.nearest(k, &objects, c)?;
|
||||
Ok((r.is_finite() && r >= h * (1.0 - 1e-9), c, r))
|
||||
};
|
||||
// First the direct step of `need` from each sample.
|
||||
for &(s, n) in &samples {
|
||||
let (inside, c, _) = ball(s, n, need)?;
|
||||
if inside && walls.nearest(k, &objects, c)?.0 >= need {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
// Then climbing, from a small ball at each sample: step away from the nearest offset face by
|
||||
// most of the ball's radius (staying inside the ball, so inside the body) while the distance
|
||||
// from the offset faces grows.
|
||||
const STEPS: usize = 48;
|
||||
for &(s, n) in &samples {
|
||||
let (inside, mut c, mut r) = ball(s, n, margin.max(1e3 * tol))?;
|
||||
if !inside {
|
||||
continue;
|
||||
}
|
||||
for _ in 0..STEPS {
|
||||
let (d, q) = walls.nearest(k, &objects, c)?;
|
||||
if d >= need {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(q) = q.filter(|_| d > 0.0 && d.is_finite()) else { break };
|
||||
let next = c + (c - q) * (0.9 * r / d);
|
||||
let (rn, _) = all.nearest(k, &objects, next)?;
|
||||
if !(rn > 0.0) || !rn.is_finite() || walls.nearest(k, &objects, next)?.0 <= d * (1.0 + 1e-9) {
|
||||
break;
|
||||
}
|
||||
c = next;
|
||||
r = rn;
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// `SmApiShellBrepFull(b, distance, TRUE, TRUE, TRUE, open, r)`: convex edges extended, results
|
||||
/// merged, side walls made at a sheet's edges, and (as the fork does, 5448b9a) a thickened
|
||||
/// sheet's coplanar side walls one face.
|
||||
fn shell_brep_full(k: &mut Kernel, b: &Brep, distance: f64, open: &[FaceId]) -> solidcore::Result<Brep> {
|
||||
let options = ShellOptions {
|
||||
offset: OffsetOptions { distance, extend_convex_edges: true, merge_results: true },
|
||||
create_offset_solid: true,
|
||||
merge_sheet_side_walls: true,
|
||||
};
|
||||
shell_brep_simple(k, b, options, open)
|
||||
}
|
||||
|
||||
/// `SmApiOffsetBrepFull(b, distance, TRUE, TRUE, r)`.
|
||||
fn offset_brep_full(k: &mut Kernel, b: &mut Brep, distance: f64) -> solidcore::Result<Brep> {
|
||||
offset_brep_simple(k, b, OffsetOptions { distance, extend_convex_edges: true, merge_results: true })
|
||||
}
|
||||
|
||||
/// `finish`: the result must have faces, and a solid input must give a closed manifold solid.
|
||||
fn finish(b: Brep, solid_in: bool) -> solidcore::Result<Out<Brep>> {
|
||||
if b.face_list().is_empty() {
|
||||
return Ok(Err(Error::Empty));
|
||||
}
|
||||
if solid_in && !brep_p2::is_manifold_solid(&b)? {
|
||||
return Ok(Err(Error::NotSolid));
|
||||
}
|
||||
Ok(Ok(b))
|
||||
}
|
||||
|
||||
/// An SMLib call's own status passed on (`status(...)` in the capi), else the value.
|
||||
fn called<T>(r: solidcore::Result<T>) -> solidcore::Result<Out<T>> {
|
||||
match r {
|
||||
Ok(v) => Ok(Ok(v)),
|
||||
Err(e) => Ok(Err(status(e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `smlib_shell`: the faces nearest `points` (each within `tolerance`) opened, the rest offset
|
||||
/// by `thickness` (negative: inward) into a wall; none gives a closed hollow body. An inward
|
||||
/// wall of a solid that leaves no cavity is [`Error::NoRoom`], refused before the shell.
|
||||
pub(crate) fn shell(body: &Body, points: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result<Body> {
|
||||
if !points_ok(points, true) || !tolerance_ok(tolerance) || !distance_ok(thickness) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("smlib_shell", body, |k, mut w| {
|
||||
let picked = match pick_faces(k, &mut w, points, tolerance)? {
|
||||
Ok(p) => p,
|
||||
Err(m) => return Ok(Err(face_miss(m))),
|
||||
};
|
||||
let solid_in = brep_p2::is_manifold_solid(&w)?;
|
||||
if thickness < 0.0 && solid_in && !leaves_cavity(k, &mut w, &picked, -thickness)? {
|
||||
return Ok(Err(Error::NoRoom));
|
||||
}
|
||||
match called(shell_brep_full(k, &w, thickness, &picked))? {
|
||||
Ok(r) => finish(r, solid_in),
|
||||
Err(e) => Ok(Err(e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `smlib_offset_faces`: its checks, the pick, the solid check, (moving faces in) the cavity
|
||||
/// check, then `move_faces`.
|
||||
pub(crate) fn offset_faces(body: &Body, points: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Body> {
|
||||
if !points_ok(points, false) || !tolerance_ok(tolerance) || !distance_ok(distance) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("smlib_offset_faces", body, |k, mut w| {
|
||||
let picked = match pick_faces(k, &mut w, points, tolerance)? {
|
||||
Ok(p) => p,
|
||||
Err(m) => return Ok(Err(face_miss(m))),
|
||||
};
|
||||
if !brep_p2::is_manifold_solid(&w)? {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
// Faces moved in so far that no material is left behind them: the body must reach
|
||||
// deeper than the move behind the moved faces.
|
||||
if distance < 0.0 {
|
||||
let others: Vec<FaceId> = brep_p1::faces(&w, None)?.into_iter().filter(|f| !picked.contains(f)).collect();
|
||||
if !leaves_cavity(k, &mut w, &others, -distance)? {
|
||||
return Ok(Err(Error::NoRoom));
|
||||
}
|
||||
}
|
||||
match move_faces(k, &mut w, &picked, distance) {
|
||||
Ok(()) => finish(w, true),
|
||||
Err(e) => Ok(Err(e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `move_faces`: moves the picked faces of `b` (in place) `distance` along their outward normals:
|
||||
/// each gets its surface's offset, and the local operation extends or trims the faces around them
|
||||
/// to meet it; the tangent blends on them follow (`SmOffsetFacesExecutive`: taken off, the faces
|
||||
/// moved, put back at their own sizes). A blend that no longer fits is [`Error::NoRoom`], one that
|
||||
/// cannot be put back as it was [`Error::Unsupported`], one that would need a continuation that
|
||||
/// cannot be made exactly [`Error::NotExact`], a move that moved nothing [`Error::NoEffect`].
|
||||
fn move_faces(k: &mut Kernel, b: &mut Brep, picked: &[FaceId], distance: f64) -> Out<()> {
|
||||
let mut exec = OffsetFacesExecutive::new();
|
||||
exec.offset_faces(k, b, picked, distance).map_err(|e| match exec.refusal() {
|
||||
OffsetFacesRefusal::NoRoom => Error::NoRoom,
|
||||
OffsetFacesRefusal::Unsupported => Error::Unsupported,
|
||||
OffsetFacesRefusal::NotExact => Error::NotExact,
|
||||
OffsetFacesRefusal::NoEffect => Error::NoEffect,
|
||||
// Not in the fork (solidcore#2020, #2027): the blends cannot be put back, the fillet's
|
||||
// status kept. SolidCraft has no such error: a move solidcore does not make.
|
||||
OffsetFacesRefusal::NotPutBack => Error::Unsupported,
|
||||
OffsetFacesRefusal::None => status(e),
|
||||
})
|
||||
}
|
||||
|
||||
/// `smlib_thicken`: a sheet thickened by `thickness` along its normals, or (symmetric) moved back
|
||||
/// by half first; a closed solid is [`Error::Argument`].
|
||||
pub(crate) fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
|
||||
if !distance_ok(thickness) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("smlib_thicken", body, |k, mut w| {
|
||||
if brep_p2::is_manifold_solid(&w)? {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
if symmetric {
|
||||
w = match called(offset_brep_full(k, &mut w, -0.5 * thickness))? {
|
||||
Ok(moved) => moved,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
}
|
||||
match called(shell_brep_full(k, &w, thickness, &[]))? {
|
||||
Ok(r) => finish(r, true),
|
||||
Err(e) => Ok(Err(e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// `smlib_offset_sheet`: every face of a sheet moved onto its surface's offset by `distance`
|
||||
/// along its normal; faces that close into a solid (a sphere's one face, a block's six) give the
|
||||
/// offset solid (tangency-labs/smlib#17, which dropped the capi's refusal of a closed solid).
|
||||
pub(crate) fn offset_sheet(body: &Body, distance: f64) -> Result<Body> {
|
||||
if !distance_ok(distance) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("smlib_offset_sheet", body, |k, mut w| {
|
||||
// `take`: a result without faces (a sphere moved in to its centre) is SMLIB_ERR_EMPTY.
|
||||
match called(offset_brep_full(k, &mut w, distance))? {
|
||||
Ok(r) => finish(r, false),
|
||||
Err(e) => Ok(Err(e)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A unit vector from `v`, as `smlib_draft`'s `unit`; `None` for a vector too short or not
|
||||
/// finite.
|
||||
fn unit(v: [f64; 3]) -> Option<Vec3> {
|
||||
if !v.iter().all(|x| x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let u = p3(v);
|
||||
let len = u.length();
|
||||
(len > 1e-12 && len.is_finite()).then(|| u * (1.0 / len))
|
||||
}
|
||||
|
||||
/// `outward_normal`: the outward normal of face `f` (its outer faceuse `fu`) at `at`, a point on
|
||||
/// it: its surface's normal there, turned with the outer faceuse. `None` where SMLib cannot place
|
||||
/// the point on the surface (the capi's `STEPInversionAnswered` also takes a point outside the
|
||||
/// domain or off the surface by more than its tolerance; solidcore's inversion answers those with
|
||||
/// a status, so they are `None` here: the face's own internal point is neither).
|
||||
fn outward_normal(k: &Kernel, b: &Brep, f: FaceId, fu: FaceuseId, at: Point3) -> solidcore::Result<Option<Vec3>> {
|
||||
let Some(surf) = b.face(f)?.surface.as_ref() else { return Ok(None) };
|
||||
let Some((step, _)) = answer(surf.step_inversion(k, &surf.step_uv_domain(), at, 1e6, None))? else { return Ok(None) };
|
||||
let Some(nurb) = answer(surf.convert_uv_from_step_to_nurbs(k, step))? else { return Ok(None) };
|
||||
let Some(mut n) = answer(surf.evaluate_normal(k, nurb, Side::Left, Side::Left))? else { return Ok(None) };
|
||||
if b.faceuse(fu)?.orientation != OrientType::Same {
|
||||
n = -n;
|
||||
}
|
||||
Ok(Some(n))
|
||||
}
|
||||
|
||||
/// `drafted_cone` (tangency-labs/smlib#27, #53): the cone a face on the cone or cylinder `cyl` about
|
||||
/// the pull (its outward normal `n` at `inside`) becomes drafted by `angle` about its circle on the
|
||||
/// neutral plane (through `origin`, normal `normal`), so that it leans in going along `pull`: its
|
||||
/// generators turned by `angle` in their planes through the axis, about that circle. With R0 the
|
||||
/// radius on the neutral plane and t the slope of the radius going along the pull (0 for a
|
||||
/// cylinder), the radius h along the pull from that circle is R0 + h tan(atan(t) - s angle), s = 1
|
||||
/// where the face looks away from the axis (a pin), -1 where it looks towards it (a hole). It is
|
||||
/// the surface's own NURB with each control point moved radially by the new radius over the old at
|
||||
/// its height, so it keeps the surface's parameters and the face's UV trims stay valid: for a
|
||||
/// cylinder solidcore's `Cone::lean_cylinder` (solidcore#1545), for a cone the same edit on
|
||||
/// [`Cone::base_mut`]. [`Error::Unsupported`] when the pull does not run along the axis, the axis
|
||||
/// lies in the neutral plane, or the cone would close within the face's span.
|
||||
#[allow(clippy::too_many_arguments)] // the capi's drafted_cone
|
||||
fn drafted_cone(
|
||||
k: &Kernel,
|
||||
cyl: &Cone,
|
||||
n: Vec3,
|
||||
inside: Point3,
|
||||
origin: Point3,
|
||||
normal: Vec3,
|
||||
pull: Vec3,
|
||||
angle: f64,
|
||||
) -> solidcore::Result<Out<Cone>> {
|
||||
let frame = cyl.canonical().origin;
|
||||
let z = frame.x_axis().cross(frame.y_axis());
|
||||
let len = z.length();
|
||||
if !(len > 0.0 && len.is_finite()) {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let z = z * (1.0 / len);
|
||||
let sense = z.dot(pull);
|
||||
if (sense.abs() - 1.0).abs() > 1e-9 {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let axis = frame.origin();
|
||||
let toward = z.dot(normal);
|
||||
if toward.abs() < 1e-12 {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
// The hinge circle's centre: the axis on the neutral plane, at the cone's height v0.
|
||||
let v0 = (origin - axis).dot(normal) / toward;
|
||||
let centre = axis + z * v0;
|
||||
let radial = (inside - centre) - pull * (inside - centre).dot(pull);
|
||||
let s = if radial.dot(n) >= 0.0 { 1.0 } else { -1.0 };
|
||||
// The cone's radius is linear in its height along z; along the pull it changes by `slope` per
|
||||
// unit, and drafted by `turned`.
|
||||
let r0 = cyl.radius(v0);
|
||||
let slope = (cyl.radius(1.0) - cyl.radius(0.0)) * sense;
|
||||
let turned = (slope.atan() - s * angle).tan();
|
||||
if !(r0 > 0.0) || !turned.is_finite() {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
if cyl.is_cylinder() {
|
||||
return Ok(answer(cyl.lean_cylinder(k, turned * sense, v0))?.ok_or(Error::Unsupported));
|
||||
}
|
||||
let mut cone = cyl.clone();
|
||||
let net = cone.base_mut();
|
||||
let (nu, nv) = (net.number_control_points(SurfParamType::U), net.number_control_points(SurfParamType::V));
|
||||
for i in 0..nu {
|
||||
for j in 0..nv {
|
||||
let Some((p, w)) = answer(net.control_point(ControlPointForm::EuclideanRational, i, j))? else {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
};
|
||||
let h = (p - centre).dot(pull);
|
||||
let (was, now) = (cyl.radius(v0 + h * sense), r0 + h * turned);
|
||||
if !(was > 0.0 && now > 0.0) {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let foot = centre + pull * h;
|
||||
if answer(net.set_control_point(ControlPointForm::EuclideanRational, i, j, foot + (p - foot) * (now / was), w))?.is_none() {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `CopyAndAddAnalytics`: the cone's analytic data from its edited NURB.
|
||||
if answer(cone.rebuild_step_from_nurb_parameters(k))?.is_none() {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
Ok(Ok(cone))
|
||||
}
|
||||
|
||||
/// The capi's `Before` mark on what `SmBrep::LocalOperation` is to move: above the history's tag.
|
||||
const MOVED_MARK: u64 = ids::USER_1 + 8;
|
||||
|
||||
/// `Before::mark` on each replacement surface. A surface has no identity apart from its owning
|
||||
/// face here, and `faces[i]`'s offset face takes `surfaces[i]`, so the mark goes on the picked face
|
||||
/// and travels as the surface's does (Copy behaviour; solidcore#1875). The one difference, recorded
|
||||
/// in solidcore's `topology.brep_local_operation--1875-face-mark-for-surface-mark` finding: a merge
|
||||
/// keeping an unpicked face over a picked one hands that survivor the mark, where SMLib's survivor
|
||||
/// keeps its own unmarked surface.
|
||||
pub(super) fn mark_moved(w: &mut Brep, picked: &[FaceId]) -> solidcore::Result<()> {
|
||||
for &f in picked {
|
||||
w.add_attribute(EntityRef::Face(f), Attribute::long(MOVED_MARK, 0, Behavior::Copy))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `moved_faces`'s reading (`read_marks`): whether any face of `w` carries the mark, every mark
|
||||
/// removed.
|
||||
pub(super) fn moved_faces(w: &mut Brep) -> solidcore::Result<bool> {
|
||||
let mut moved = false;
|
||||
for f in w.face_list().to_vec() {
|
||||
let e = EntityRef::Face(f);
|
||||
let marks: Vec<_> = w.entity_attributes(e).into_iter().filter(|(_, a)| a.attribute_id() == MOVED_MARK).map(|(h, _)| h).collect();
|
||||
moved = moved || !marks.is_empty();
|
||||
for h in marks {
|
||||
w.remove_attribute(e, h)?;
|
||||
}
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// `smlib_draft`: its checks, the solid check, the pick, then `draft_faces`: each picked face (a
|
||||
/// plane parallel to the pull) gets a copy of its plane turned by `angle` about its line on the
|
||||
/// neutral plane, and a cylinder about the pull the cone `drafted_cone` gives
|
||||
/// (tangency-labs/smlib#27); `SmBrep::LocalOperation` extends or trims the faces around them to
|
||||
/// meet the tilted surfaces.
|
||||
pub(crate) fn draft(
|
||||
body: &Body,
|
||||
points: &[[f64; 3]],
|
||||
tolerance: f64,
|
||||
neutral_origin: [f64; 3],
|
||||
neutral_normal: [f64; 3],
|
||||
pull: [f64; 3],
|
||||
angle: f64,
|
||||
) -> Result<Body> {
|
||||
let (Some(normal), Some(along)) = (unit(neutral_normal), unit(pull)) else { return Err(Error::Argument) };
|
||||
if !points_ok(points, false)
|
||||
|| !tolerance_ok(tolerance)
|
||||
|| !neutral_origin.iter().all(|x| x.is_finite() && x.abs() <= 1e9)
|
||||
|| !angle.is_finite()
|
||||
|| angle == 0.0
|
||||
|| angle.abs() >= 1.5697963267948966
|
||||
{
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("smlib_draft", body, |k, mut w| {
|
||||
if !brep_p2::is_manifold_solid(&w)? {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
let picked = match pick_faces(k, &mut w, points, tolerance)? {
|
||||
Ok(p) => p,
|
||||
Err(m) => return Ok(Err(face_miss(m))),
|
||||
};
|
||||
// `draft_faces`.
|
||||
let mut surfaces = Vec::with_capacity(picked.len());
|
||||
for &f in &picked {
|
||||
let fu = outer_faceuse(&w, f)?;
|
||||
let face = w.face(f)?;
|
||||
let (Some(surf), Some(fu)) = (face.surface.as_ref(), fu) else {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
};
|
||||
// A cylinder about the pull drafted becomes a cone (`drafted_cone`).
|
||||
if let SurfaceKind::Cone(c) = surf.kind_with_nurb(k)? {
|
||||
let cyl = (**c).clone();
|
||||
let Some(inside) = answer(face_internal_point(k, &mut w, f))? else {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
};
|
||||
let Some(n) = outward_normal(k, &w, f, fu, inside)? else {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
};
|
||||
match drafted_cone(k, &cyl, n, inside, p3(neutral_origin), normal, along, angle)? {
|
||||
Ok(cone) => surfaces.push(Surface::from(cone)),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let SurfaceKind::Plane(plane) = surf.kind_with_nurb(k)? else {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
};
|
||||
// The face's outward normal: the plane's, turned with the outer faceuse.
|
||||
let mut n = plane.position().z_axis();
|
||||
if w.faceuse(fu)?.orientation != OrientType::Same {
|
||||
n = -n;
|
||||
}
|
||||
if n.dot(along).abs() > 1e-6 {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let Some(inside) = answer(face_internal_point(k, &mut w, f))? else {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
};
|
||||
let toward = along.dot(normal);
|
||||
if toward.abs() < 1e-12 {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
let hinge = inside + along * ((p3(neutral_origin) - inside).dot(normal) / toward);
|
||||
// Turning n about n x pull by `angle` gives n cos(angle) + pull sin(angle).
|
||||
let axis = n.cross(along);
|
||||
let len = axis.length();
|
||||
if !(len > 0.5) {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let axis = axis * (1.0 / len);
|
||||
let Some(surf) = w.face(f)?.surface.as_ref() else {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
};
|
||||
let Some(mut tilted) = answer(surf.copy(k))? else {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
};
|
||||
let mut turn = Axis2Placement::IDENTITY;
|
||||
turn.rotate_about_axis_at_point(angle, hinge, axis);
|
||||
if answer(tilted.transform(k, &turn, None))?.is_none() {
|
||||
return Ok(Err(Error::Status(INVALID_INPUT)));
|
||||
}
|
||||
surfaces.push(tilted);
|
||||
}
|
||||
// `moved_faces(b, status(b->LocalOperation(faces, surfaces)))`, its marks on the picked
|
||||
// faces (`mark_moved`).
|
||||
mark_moved(&mut w, &picked)?;
|
||||
if let Err(e) = called(local_operation_simple(k, &mut w, &picked, surfaces))? {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if !moved_faces(&mut w)? {
|
||||
return Ok(Err(Error::NoEffect));
|
||||
}
|
||||
finish(w, true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
//! Shells, face offsets, thickening, sheet offsets and drafts on solidcore, against closed forms:
|
||||
//! every result exports to a record that validates. With SMLib built, the same case on the C++
|
||||
//! kernel gives the same volume (or, for a sheet, area), counts and faces by surface kind, or the
|
||||
//! same error (parity only: the expected values are the analytic ones).
|
||||
//!
|
||||
//! The bodies are solidcore's own boxes, cylinders and linear sweeps (and booleans of them).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::PI;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::curve::ellipse::Ellipse;
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::extent1d::Extent1d;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::primitive::api_curves::create_line_segment;
|
||||
use solidcore::sweep::api_brep::create_linear_sweep;
|
||||
|
||||
use super::offset::{draft, mark_moved, moved_faces, offset_faces, offset_sheet, shell, thicken};
|
||||
use super::{Body, area, bounds, counts, make_box, make_cylinder, make_sphere, run, to_arrays, volume};
|
||||
use crate::export::native;
|
||||
use crate::{BooleanOp, Error};
|
||||
|
||||
/// A case longer than this is a hang.
|
||||
const TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How near a pick point must be.
|
||||
const TOL: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Shape {
|
||||
Box([f64; 3], [f64; 3]),
|
||||
Cylinder([f64; 3], f64, f64),
|
||||
Sphere([f64; 3], f64),
|
||||
/// A frustum about +Z: base centre, base radius, top radius, height.
|
||||
Frustum([f64; 3], f64, f64, f64),
|
||||
/// A sheet: the line from the first point to the second swept along `dir` by `dist`.
|
||||
LineSheet([f64; 3], [f64; 3], [f64; 3], f64),
|
||||
/// A sheet: the arc about +Z through `center` of `radius` from 0 to `degrees`, swept along
|
||||
/// +Z by `height`.
|
||||
ArcSheet([f64; 3], f64, f64, f64),
|
||||
/// Rig case shell/4711: a 60 x 40 x 10 block less an r3 through hole at (22.5, 7) and a
|
||||
/// 10 x 10 notch on the front edge from x = 25 to 35.
|
||||
Notched,
|
||||
}
|
||||
|
||||
fn v3(p: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(p[0], p[1], p[2])
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn core(self) -> Body {
|
||||
match self {
|
||||
Shape::Box(o, s) => make_box(o, s).unwrap(),
|
||||
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h).unwrap(),
|
||||
Shape::Sphere(o, r) => make_sphere(o, r).unwrap(),
|
||||
Shape::Frustum(o, a, b, h) => super::make_cone(o, a, b, h).unwrap(),
|
||||
Shape::LineSheet(a, b, dir, dist) => run("sheet", |k| {
|
||||
let c = Curve::from(create_line_segment(v3(a), v3(b))?);
|
||||
create_linear_sweep(k, &[Some(&c)], v3(dir), dist, false)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap(),
|
||||
Shape::ArcSheet(o, r, degrees, h) => run("sheet", |k| {
|
||||
let frame = Axis2Placement::new(v3(o), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
|
||||
let c = Curve::from(Ellipse::create_canonical_circle(k, &frame, r, Some(&Extent1d::from_min_max(0.0, degrees)), None)?);
|
||||
create_linear_sweep(k, &[Some(&c)], Vec3::new(0.0, 0.0, 1.0), h, false)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap(),
|
||||
Shape::Notched => {
|
||||
let block = make_box([0.0; 3], [60.0, 40.0, 10.0]).unwrap();
|
||||
let pin = make_cylinder([22.5, 7.0, -5.0], 3.0, 20.0).unwrap();
|
||||
let notch = make_box([25.0, -5.0, -5.0], [10.0, 10.0, 20.0]).unwrap();
|
||||
let a = super::boolean::boolean(&block, &pin, BooleanOp::Subtract).unwrap().unwrap();
|
||||
super::boolean::boolean(&a, ¬ch, BooleanOp::Subtract).unwrap().unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Op {
|
||||
/// `Brep::shell`: open faces, thickness.
|
||||
Shell(Shape, Vec<[f64; 3]>, f64),
|
||||
/// `Brep::offset_faces`: faces, distance.
|
||||
OffsetFaces(Shape, Vec<[f64; 3]>, f64),
|
||||
/// `Brep::thicken`: thickness, symmetric.
|
||||
Thicken(Shape, f64, bool),
|
||||
/// `Brep::offset_sheet`: distance.
|
||||
OffsetSheet(Shape, f64),
|
||||
/// `Brep::draft`: faces, neutral origin, neutral normal, pull, angle.
|
||||
Draft(Shape, Vec<[f64; 3]>, [f64; 3], [f64; 3], [f64; 3], f64),
|
||||
}
|
||||
|
||||
/// What a case must give.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Expect {
|
||||
/// A solid of this volume (relative 1e-9).
|
||||
Volume(f64),
|
||||
/// A solid of this volume whose bounds along z are these.
|
||||
Slab(f64, f64, f64),
|
||||
/// A solid of this volume (relative 1e-9) whose bounds are these.
|
||||
Solid(f64, [f64; 3], [f64; 3]),
|
||||
/// A sheet of this area (relative 1e-9) whose bounds are these.
|
||||
Sheet(f64, [f64; 3], [f64; 3]),
|
||||
/// This error.
|
||||
Fails(Error),
|
||||
}
|
||||
|
||||
struct Case {
|
||||
op: Op,
|
||||
expect: Expect,
|
||||
}
|
||||
|
||||
/// A 30 x 20 x 10 block from the origin.
|
||||
const BLOCK: Shape = Shape::Box([0.0; 3], [30.0, 20.0, 10.0]);
|
||||
const ABC: f64 = 6000.0;
|
||||
/// A point on its top face.
|
||||
const TOP: [f64; 3] = [15.0, 10.0, 10.0];
|
||||
/// A point on its front face (y = 0).
|
||||
const FRONT: [f64; 3] = [15.0, 0.0, 5.0];
|
||||
/// The z axis: a neutral plane's normal and a pull.
|
||||
const Z: [f64; 3] = [0.0, 0.0, 1.0];
|
||||
/// A 30 x 20 sheet in z = 0: the x axis from 0 to 30 swept 20 along y.
|
||||
const PLATE: Shape = Shape::LineSheet([0.0; 3], [30.0, 0.0, 0.0], [0.0, 1.0, 0.0], 20.0);
|
||||
/// A sphere of radius 10 about the origin.
|
||||
const BALL: Shape = Shape::Sphere([0.0; 3], 10.0);
|
||||
/// A quarter of a cylinder of radius 5 about the z axis, 10 high.
|
||||
const QUARTER: Shape = Shape::ArcSheet([0.0; 3], 5.0, 90.0, 10.0);
|
||||
|
||||
fn case(name: &str) -> Case {
|
||||
let c = |op, expect| Case { op, expect };
|
||||
match name {
|
||||
// abc - (a - 2t)(b - 2t)(c - t), t = 2.
|
||||
"shell_box_open_top" => c(Op::Shell(BLOCK, vec![TOP], -2.0), Expect::Volume(ABC - 26.0 * 16.0 * 8.0)),
|
||||
// A void: abc - (a - 2t)(b - 2t)(c - 2t).
|
||||
"shell_box_closed" => c(Op::Shell(BLOCK, vec![], -2.0), Expect::Volume(ABC - 26.0 * 16.0 * 6.0)),
|
||||
// Closed and outward, the convex edges extended: (a + 2t)(b + 2t)(c + 2t) - abc, t = 1.
|
||||
"shell_box_closed_outward" => c(Op::Shell(BLOCK, vec![], 1.0), Expect::Volume(32.0 * 22.0 * 12.0 - ABC)),
|
||||
// Outward, the convex edges extended: (a + 2t)(b + 2t)(c + t) - abc, t = 1.
|
||||
"shell_box_outward" => c(Op::Shell(BLOCK, vec![TOP], 1.0), Expect::Volume(32.0 * 22.0 * 11.0 - ABC)),
|
||||
// r = 5, h = 10, open at the top, t = 1: π r² h - π (r - t)² (h - t).
|
||||
"shell_cylinder_open_top" => {
|
||||
c(Op::Shell(Shape::Cylinder([0.0; 3], 5.0, 10.0), vec![[0.0, 2.0, 10.0]], -1.0), Expect::Volume(PI * 25.0 * 10.0 - PI * 16.0 * 9.0))
|
||||
}
|
||||
// A wall of 15 inside a block 20 wide leaves no cavity.
|
||||
"shell_too_thick" => c(Op::Shell(BLOCK, vec![TOP], -15.0), Expect::Fails(Error::NoRoom)),
|
||||
// The hole's offset (r 3 + 2) is tangent to the front's (y = 2): the walls meet along a
|
||||
// line, which bounds no solid.
|
||||
"shell_walls_touch" => c(Op::Shell(Shape::Notched, vec![[50.0, 30.0, 0.0]], -2.0), Expect::Fails(Error::WallsTouch)),
|
||||
"shell_pick_near_nothing" => c(Op::Shell(BLOCK, vec![TOP, [100.0; 3]], -2.0), Expect::Fails(Error::NoMatch { point: 1 })),
|
||||
// The top front edge's midpoint is as near the top as the front.
|
||||
"shell_pick_ambiguous" => c(Op::Shell(BLOCK, vec![[15.0, 0.0, 10.0]], -2.0), Expect::Fails(Error::Ambiguous { point: 0 })),
|
||||
// A plate of 30 x 20 thickened 2: 1200, 2 thick along z; symmetric, from -1 to 1.
|
||||
"thicken_plate" => c(Op::Thicken(PLATE, 2.0, false), Expect::Slab(1200.0, 0.0, 2.0)),
|
||||
"thicken_plate_symmetric" => c(Op::Thicken(PLATE, 2.0, true), Expect::Slab(1200.0, -1.0, 1.0)),
|
||||
"thicken_a_solid" => c(Op::Thicken(BLOCK, 2.0, false), Expect::Fails(Error::Argument)),
|
||||
// The plate moved 3 along its normal (+z: the x axis swept along y): still 600, at z = 3.
|
||||
"offset_sheet_plate" => c(Op::OffsetSheet(PLATE, 3.0), Expect::Sheet(600.0, [0.0, 0.0, 3.0], [30.0, 20.0, 3.0])),
|
||||
// A sphere's one face closes on itself (tangency-labs/smlib fix #17): r = 10 moved out 2 is
|
||||
// the ball of radius 12, (4/3) π 12³; moved in 10, to its centre, nothing is left.
|
||||
"offset_sheet_a_sphere" => c(Op::OffsetSheet(BALL, 2.0), Expect::Volume(4.0 / 3.0 * PI * 12.0f64.powi(3))),
|
||||
"offset_sheet_a_sphere_to_its_centre" => c(Op::OffsetSheet(BALL, -10.0), Expect::Fails(Error::Empty)),
|
||||
// The quarter cylinder moved 2 along its normal (outward: the arc's tangent swept along
|
||||
// z): radius 7, area (π/2) 7 · 10.
|
||||
"offset_sheet_quarter_cylinder" => c(Op::OffsetSheet(QUARTER, 2.0), Expect::Sheet(PI / 2.0 * 7.0 * 10.0, [0.0, 0.0, 0.0], [7.0, 7.0, 10.0])),
|
||||
// A closed sheet offsets as an open one (tangency-labs/smlib fix #17, develop 9d0fb564):
|
||||
// each face moved out 1 along its normal, the convex edges extended, gives the block
|
||||
// (a + 2d)(b + 2d)(c + 2d) of six planes, from -d to (a, b, c) + d.
|
||||
"offset_sheet_a_solid" => c(Op::OffsetSheet(BLOCK, 1.0), Expect::Solid(32.0 * 22.0 * 12.0, [-1.0, -1.0, -1.0], [31.0, 21.0, 11.0])),
|
||||
// The top moved out 2: abc + d·ab.
|
||||
"offset_faces_top_out" => c(Op::OffsetFaces(BLOCK, vec![TOP], 2.0), Expect::Volume(ABC + 2.0 * 600.0)),
|
||||
// The top moved in 10, as deep as the block: nothing left behind it.
|
||||
"offset_faces_through" => c(Op::OffsetFaces(BLOCK, vec![TOP], -10.0), Expect::Fails(Error::NoRoom)),
|
||||
// The four sides drafted θ = 5° about the bottom (z = 0), pulled along +z, leaning in:
|
||||
// the section at height z is (a - 2 z tanθ)(b - 2 z tanθ), so
|
||||
// V = abh - (a + b) h² tanθ + (4/3) h³ tan²θ.
|
||||
"draft_box_sides" => {
|
||||
let s = (5.0f64).to_radians().tan();
|
||||
let (a, b, h) = (30.0, 20.0, 10.0);
|
||||
let sides = vec![[15.0, 0.0, 5.0], [30.0, 10.0, 5.0], [15.0, 20.0, 5.0], [0.0, 10.0, 5.0]];
|
||||
c(
|
||||
Op::Draft(BLOCK, sides, [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 5f64.to_radians()),
|
||||
Expect::Volume(a * b * h - (a + b) * h * h * s + 4.0 / 3.0 * h * h * h * s * s),
|
||||
)
|
||||
}
|
||||
// The front (y = 0) alone drafted θ = 5° about the bottom: a wedge of a h² tanθ / 2
|
||||
// comes off along its length.
|
||||
"draft_one_side" => {
|
||||
let s = (5.0f64).to_radians().tan();
|
||||
c(Op::Draft(BLOCK, vec![FRONT], [0.0; 3], Z, Z, 5f64.to_radians()), Expect::Volume(ABC - 30.0 * 100.0 * s / 2.0))
|
||||
}
|
||||
// A negative angle leans it out: the same wedge goes on.
|
||||
"draft_one_side_out" => {
|
||||
let s = (5.0f64).to_radians().tan();
|
||||
c(Op::Draft(BLOCK, vec![FRONT], [0.0; 3], Z, Z, -5f64.to_radians()), Expect::Volume(ABC + 30.0 * 100.0 * s / 2.0))
|
||||
}
|
||||
// Hinged at the top (z = 10) and leaning in going up, the face leans out below the
|
||||
// hinge: the wedge goes on.
|
||||
"draft_one_side_hinged_at_the_top" => {
|
||||
let s = (5.0f64).to_radians().tan();
|
||||
c(Op::Draft(BLOCK, vec![FRONT], [0.0, 0.0, 10.0], Z, Z, 5f64.to_radians()), Expect::Volume(ABC + 30.0 * 100.0 * s / 2.0))
|
||||
}
|
||||
// A cylinder along the pull drafts to a cone (tangency-labs/smlib fix #27, develop
|
||||
// 9d0fb564): R = 5, h = 10, θ = 0.1 about the bottom, leaning in: a frustum of radii R
|
||||
// and r = R - h tanθ (a cone and its two caps): V = π h (R² + R r + r²) / 3.
|
||||
"draft_a_curved_face" => {
|
||||
let (big, h) = (5.0, 10.0);
|
||||
let small = big - h * 0.1f64.tan();
|
||||
c(
|
||||
Op::Draft(Shape::Cylinder([0.0; 3], big, h), vec![[5.0, 0.0, 5.0]], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 0.1),
|
||||
Expect::Volume(PI * h * (big * big + big * small + small * small) / 3.0),
|
||||
)
|
||||
}
|
||||
// A cone r 10 at its base to 8 at its top, 20 tall, drafted 5° about its base turns its
|
||||
// generators 5° more (fork d51a723): r 10 + 20 tan(atan(-0.1) - 5°) at the top.
|
||||
"draft_a_cone_about_the_pull" => {
|
||||
let (big, h, a) = (10.0, 20.0, 5f64.to_radians());
|
||||
let small = big + h * ((-0.1f64).atan() - a).tan();
|
||||
c(
|
||||
Op::Draft(Shape::Frustum([0.0; 3], big, 8.0, h), vec![[0.0, 9.0, 10.0]], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], a),
|
||||
Expect::Volume(PI * h * (big * big + big * small + small * small) / 3.0),
|
||||
)
|
||||
}
|
||||
// The top is square to the pull, not along it.
|
||||
"draft_a_face_across_the_pull" => {
|
||||
c(Op::Draft(BLOCK, vec![TOP], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 0.1), Expect::Fails(Error::Unsupported))
|
||||
}
|
||||
_ => panic!("{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tolerance a case picks with: the ambiguous point lies on its two faces.
|
||||
fn tolerance(name: &str) -> f64 {
|
||||
if name == "shell_pick_ambiguous" { 1.0 } else { TOL }
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Measured {
|
||||
/// The volume of a solid, the area of a sheet.
|
||||
size: f64,
|
||||
bounds: ([f64; 3], [f64; 3]),
|
||||
counts: [usize; 3],
|
||||
kinds: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for f in &rec.faces {
|
||||
let v = serde_json::to_value(&f.surface).unwrap();
|
||||
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_sheet(e: &Expect) -> bool {
|
||||
matches!(e, Expect::Sheet(..))
|
||||
}
|
||||
|
||||
fn on_core(name: &str, c: &Case) -> crate::Result<Body> {
|
||||
let tol = tolerance(name);
|
||||
match &c.op {
|
||||
Op::Shell(s, points, t) => shell(&s.core(), points, tol, *t),
|
||||
Op::OffsetFaces(s, points, d) => offset_faces(&s.core(), points, tol, *d),
|
||||
Op::Thicken(s, t, sym) => thicken(&s.core(), *t, *sym),
|
||||
Op::OffsetSheet(s, d) => offset_sheet(&s.core(), *d),
|
||||
Op::Draft(s, points, o, n, pull, angle) => draft(&s.core(), points, tol, *o, *n, *pull, *angle),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
|
||||
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
match rx.recv_timeout(TIMEOUT) {
|
||||
Ok(v) => v,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
Ok(()) => panic!("{name}: no result"),
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
type Got = std::result::Result<Measured, Error>;
|
||||
|
||||
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// Checks a kernel's result against the case's closed form; what it measured, or its error.
|
||||
fn judge(name: &str, who: &str, expect: &Expect, got: Got) -> Got {
|
||||
match (expect, &got) {
|
||||
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
|
||||
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
|
||||
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(Expect::Volume(v), Ok(m)) => assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size),
|
||||
(Expect::Slab(v, lo, hi), Ok(m)) => {
|
||||
assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size);
|
||||
let (a, b) = (m.bounds.0[2], m.bounds.1[2]);
|
||||
assert!((a - lo).abs() < 1e-6 && (b - hi).abs() < 1e-6, "{name}: {who} z from {a} to {b}, expected {lo} to {hi}");
|
||||
}
|
||||
(Expect::Solid(v, lo, hi), Ok(m)) => {
|
||||
assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size);
|
||||
assert!(near(m.bounds.0, *lo) && near(m.bounds.1, *hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds);
|
||||
}
|
||||
(Expect::Sheet(s, lo, hi), Ok(m)) => {
|
||||
assert!(rel(m.size, *s) < 1e-9, "{name}: {who} area {} vs analytic {s}", m.size);
|
||||
assert!(near(m.bounds.0, *lo) && near(m.bounds.1, *hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds);
|
||||
}
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn solidcore_case(name: &'static str) -> Got {
|
||||
timed(name, move || {
|
||||
let c = case(name);
|
||||
let sheet = is_sheet(&c.expect);
|
||||
let got = on_core(name, &c).map(|body| {
|
||||
let size = if sheet { area(&body, 1e-8) } else { volume(&body, 1e-8) }.unwrap_or_else(|e| panic!("{name}: size: {e}"));
|
||||
let n = counts(&body).unwrap();
|
||||
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
let bx = bounds(&body).unwrap();
|
||||
Measured { size, bounds: bx, counts: n, kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "solidcore", &c.expect, got)
|
||||
})
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, called directly (whatever the choice).
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Got {
|
||||
use crate::ops::pick_error;
|
||||
use crate::sweeps::DraftCorner;
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let line = |a: [f64; 3], b: [f64; 3]| crate::sweeps::Curve::Line { start: a, end: b };
|
||||
let make = |s: Shape| -> Brep {
|
||||
match s {
|
||||
Shape::Box(o, z) => ffi::make_box(&o, &z).map(Brep::made).unwrap(),
|
||||
Shape::Cylinder(o, r, h) => ffi::make_cylinder(&o, r, h).map(Brep::made).unwrap(),
|
||||
Shape::Sphere(o, r) => ffi::make_sphere(&o, r).map(Brep::made).unwrap(),
|
||||
Shape::Frustum(o, a, b, h) => ffi::make_cone(&o, a, b, h).map(Brep::made).unwrap(),
|
||||
Shape::LineSheet(a, b, dir, dist) => Brep::extrude(&[line(a, b)], dir, dist, 0.0, DraftCorner::default(), false).unwrap(),
|
||||
Shape::ArcSheet(o, r, degrees, h) => {
|
||||
let arc = crate::sweeps::Curve::Arc {
|
||||
center: o,
|
||||
axis: [0.0, 0.0, 1.0],
|
||||
reference: [1.0, 0.0, 0.0],
|
||||
radius: r,
|
||||
start: 0.0,
|
||||
end: degrees.to_radians(),
|
||||
};
|
||||
Brep::extrude(&[arc], [0.0, 0.0, 1.0], h, 0.0, DraftCorner::default(), false).unwrap()
|
||||
}
|
||||
Shape::Notched => {
|
||||
let block = ffi::make_box(&[0.0; 3], &[60.0, 40.0, 10.0]).unwrap();
|
||||
let pin = ffi::make_cylinder(&[22.5, 7.0, -5.0], 3.0, 20.0).unwrap();
|
||||
let notch = ffi::make_box(&[25.0, -5.0, -5.0], &[10.0, 10.0, 20.0]).unwrap();
|
||||
let a = ffi::ops::boolean(&block, &pin, ffi::ops::BOOLEAN_SUBTRACT).unwrap();
|
||||
Brep::made(ffi::ops::boolean(&a, ¬ch, ffi::ops::BOOLEAN_SUBTRACT).unwrap())
|
||||
}
|
||||
}
|
||||
};
|
||||
let c = case(name);
|
||||
let tol = tolerance(name);
|
||||
let r = match &c.op {
|
||||
Op::Shell(s, points, t) => ffi::ops::pick_op(ffi::ops::Pick::Shell, make(*s).cpp().unwrap(), points, tol, *t).map_err(pick_error),
|
||||
Op::OffsetFaces(s, points, d) => ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, make(*s).cpp().unwrap(), points, tol, *d).map_err(pick_error),
|
||||
Op::Thicken(s, t, sym) => ffi::ops::thicken(make(*s).cpp().unwrap(), *t, *sym).map_err(error),
|
||||
Op::OffsetSheet(s, d) => ffi::sheets::offset_sheet(make(*s).cpp().unwrap(), *d).map_err(error),
|
||||
Op::Draft(s, points, o, n, pull, angle) => ffi::ops::draft(make(*s).cpp().unwrap(), points, tol, o, n, pull, *angle).map_err(pick_error),
|
||||
};
|
||||
let sheet = is_sheet(&c.expect);
|
||||
let got = r.map(|h| {
|
||||
let size = if sheet { ffi::measure::area(&h, 1e-8) } else { ffi::volume(&h, 1e-8) }.unwrap();
|
||||
let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let bx = ffi::measure::bounds(&h).unwrap();
|
||||
let b = Brep::made(h);
|
||||
let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Measured { size, bounds: bx, counts: n, kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "C++", &c.expect, got)
|
||||
}
|
||||
|
||||
/// solidcore and C++ agree: volume (area) within 1e-9, the same counts and faces by kind, or the
|
||||
/// same error.
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &'static str) {
|
||||
let cpp = cpp_case(name);
|
||||
let ours = solidcore_case(name);
|
||||
match (ours, cpp) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
assert!(rel(a.size, b.size) < 1e-9, "{name}: solidcore {} vs C++ {}", a.size, b.size);
|
||||
assert_eq!(a.counts, b.counts, "{name}: face, edge, vertex counts (solidcore vs C++)");
|
||||
assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)");
|
||||
}
|
||||
(a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::cpp_case(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
cases!(
|
||||
shell_box_open_top,
|
||||
shell_box_closed,
|
||||
shell_box_closed_outward,
|
||||
shell_box_outward,
|
||||
shell_cylinder_open_top,
|
||||
shell_too_thick,
|
||||
shell_walls_touch,
|
||||
shell_pick_near_nothing,
|
||||
shell_pick_ambiguous,
|
||||
thicken_plate,
|
||||
thicken_plate_symmetric,
|
||||
thicken_a_solid,
|
||||
offset_sheet_plate,
|
||||
offset_sheet_quarter_cylinder,
|
||||
offset_sheet_a_solid,
|
||||
offset_sheet_a_sphere,
|
||||
offset_sheet_a_sphere_to_its_centre,
|
||||
offset_faces_top_out,
|
||||
offset_faces_through,
|
||||
draft_box_sides,
|
||||
draft_one_side,
|
||||
draft_one_side_out,
|
||||
draft_one_side_hinged_at_the_top,
|
||||
draft_a_curved_face,
|
||||
draft_a_cone_about_the_pull,
|
||||
draft_a_face_across_the_pull,
|
||||
);
|
||||
|
||||
/// The capi's argument checks, before any kernel call.
|
||||
#[test]
|
||||
fn hostile_arguments_are_refused() {
|
||||
let b = BLOCK.core();
|
||||
for t in [0.0, f64::NAN, -1e7, f64::INFINITY] {
|
||||
assert_eq!(shell(&b, &[TOP], TOL, t).err(), Some(Error::Argument), "thickness {t}");
|
||||
assert_eq!(thicken(&b, t, false).err(), Some(Error::Argument), "thickness {t}");
|
||||
assert_eq!(offset_sheet(&b, t).err(), Some(Error::Argument), "distance {t}");
|
||||
assert_eq!(offset_faces(&b, &[TOP], TOL, t).err(), Some(Error::Argument), "distance {t}");
|
||||
}
|
||||
assert_eq!(shell(&b, &[[f64::NAN; 3]], TOL, -1.0).err(), Some(Error::Argument));
|
||||
assert_eq!(shell(&b, &[TOP], 0.0, -1.0).err(), Some(Error::Argument));
|
||||
assert_eq!(offset_faces(&b, &[], TOL, 1.0).err(), Some(Error::Argument));
|
||||
let z = [0.0, 0.0, 1.0];
|
||||
assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], [0.0; 3], z, 0.1).err(), Some(Error::Argument), "no neutral normal");
|
||||
assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], z, z, 0.0).err(), Some(Error::Argument), "no angle");
|
||||
assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], z, z, PI / 2.0).err(), Some(Error::Argument), "a right angle");
|
||||
assert_eq!(draft(&b, &[TOP], TOL, [f64::NAN; 3], z, z, 0.1).err(), Some(Error::Argument));
|
||||
}
|
||||
|
||||
/// The body offset is never changed.
|
||||
#[test]
|
||||
fn the_body_stays_as_it_was() {
|
||||
let r = timed("operand", || {
|
||||
let b = BLOCK.core();
|
||||
let _ = shell(&b, &[TOP], TOL, -2.0);
|
||||
(counts(&b).unwrap(), volume(&b, 1e-8).unwrap())
|
||||
});
|
||||
assert_eq!(r.0, [6, 12, 8]);
|
||||
assert!(rel(r.1, ABC) < 1e-12, "{}", r.1);
|
||||
}
|
||||
|
||||
/// The distance from `p` to each face of `b` (`SmApiFaceClosestPoint`), under `identity`.
|
||||
fn face_distances(
|
||||
k: &mut solidcore::kernel::Kernel,
|
||||
b: &mut solidcore::topology::Brep,
|
||||
identity: &solidcore::query::brep::BrepIdentity<'_>,
|
||||
p: Vec3,
|
||||
) -> solidcore::Result<Vec<(f64, Vec3)>> {
|
||||
use solidcore::geom::object::Shape as Sh;
|
||||
use solidcore::query::api_queries::face_closest_point_in_shape;
|
||||
use solidcore::query::brep::LiveBrepObjects;
|
||||
use solidcore::topology::BrepId;
|
||||
let one = BrepId::from_index(0);
|
||||
let mut out = Vec::new();
|
||||
for f in solidcore::topology::brep::p1::faces(b, None)? {
|
||||
let shape = Sh::new(k, &(one, &*b), &[], &[(one, f)], &[], &[])?;
|
||||
let objects = LiveBrepObjects::new(b, identity, None)?;
|
||||
let c = face_closest_point_in_shape(k, &shape, &objects, (one, f), p)?;
|
||||
out.push((c.distance, c.point));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The closed form for (15, 10, 20) above the 30 x 20 x 10 box at the origin, face by face of
|
||||
/// `create_box`: bottom 20, top 10, the x sides sqrt(15^2 + 10^2), the y sides sqrt(10^2 + 10^2).
|
||||
fn box_face_distances_are_exact(what: &str, d: &[(f64, Vec3)]) {
|
||||
let want = [20.0, 10.0, 325f64.sqrt(), 325f64.sqrt(), 200f64.sqrt(), 200f64.sqrt()];
|
||||
assert_eq!(d.len(), want.len(), "{what}: faces");
|
||||
for (i, (&(got, _), want)) in d.iter().zip(want).enumerate() {
|
||||
assert!((got - want).abs() < 1e-9, "{what}: face {i} at {got}, want {want}");
|
||||
}
|
||||
let top = d[1].1;
|
||||
assert!((top.x - 15.0).abs() < 1e-9 && (top.y - 10.0).abs() < 1e-9 && (top.z - 10.0).abs() < 1e-9, "{what}: top {top:?}");
|
||||
}
|
||||
|
||||
/// Picking faces on a solidcore box: on the body itself, on the working copy every operation
|
||||
/// makes (`brep_copy`), and by solidcore's recommended usage, each face's distance is the closed
|
||||
/// form and the top face is picked.
|
||||
#[test]
|
||||
fn faces_are_picked_on_the_box_and_on_its_working_copy() {
|
||||
use solidcore::query::brep::{BrepIdentity, BrepQuery};
|
||||
let p = Vec3::new(15.0, 10.0, 20.0);
|
||||
let on_top = [[15.0, 10.0, 10.0]];
|
||||
run("pick", |k| {
|
||||
let mut b = solidcore::primitive::api_primitives::create_box(k, Vec3::new(0.0, 0.0, 0.0), 30.0, 20.0, 10.0)?;
|
||||
let mut w = solidcore::topology::brep::p3::brep_copy(k, &b)?;
|
||||
let mut faces = Vec::new();
|
||||
for (what, x) in [("the body", &mut b), ("its copy", &mut w)] {
|
||||
let query = BrepQuery::of_brep(x);
|
||||
let identity = query.effective_identity();
|
||||
box_face_distances_are_exact(what, &face_distances(k, x, &identity, p)?);
|
||||
let picked = super::offset::pick_faces(k, x, &on_top, TOL)?;
|
||||
faces.push(picked.ok());
|
||||
}
|
||||
assert!(faces[0].as_ref().is_some_and(|f| f.len() == 1), "the body: {faces:?}");
|
||||
assert_eq!(faces[0], faces[1], "the body and its copy pick the same face");
|
||||
// `BrepIdentity::of_brep` honours a copy's owner keys since solidcore#1445: the body
|
||||
// and its copy both resolve.
|
||||
for (what, x) in [("of_brep on the body", &mut b), ("of_brep on its copy", &mut w)] {
|
||||
let proven = BrepIdentity::of_brep(x);
|
||||
box_face_distances_are_exact(what, &face_distances(k, x, &proven.identity(), p)?);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A pick skips an entity whose query returns an SMLib status, as the capi skips any status
|
||||
/// other than `SM_SUCCESS`; any other solidcore error fails the pick.
|
||||
#[test]
|
||||
fn a_solidcore_error_in_a_pick_is_an_error() {
|
||||
use super::answer;
|
||||
use super::blend::pick;
|
||||
use solidcore::SolidError;
|
||||
let all = [0usize, 1];
|
||||
let points = [[0.0; 3]];
|
||||
let status = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::sm_err("q")) } else { Ok(0.5) }));
|
||||
assert_eq!(status.unwrap().unwrap().0, vec![1], "a status skips the entity");
|
||||
let other = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::invalid_input("q", "owner")) } else { Ok(0.5) }));
|
||||
assert!(matches!(other, Err(SolidError::InvalidInput { .. })), "{other:?}");
|
||||
let unported = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::unported("Sm::X", "u")) } else { Ok(0.5) }));
|
||||
assert!(matches!(unported, Err(SolidError::Unported { .. })), "{unported:?}");
|
||||
}
|
||||
|
||||
/// The capi's `moved_faces` reading: no mark, no move (SMLIB_ERR_NO_EFFECT); a mark is read once,
|
||||
/// and gone.
|
||||
#[test]
|
||||
fn a_draft_with_no_marked_face_left_has_no_effect() {
|
||||
let body = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
super::with_body("marks", &body, |_, w| {
|
||||
assert!(!moved_faces(w)?, "no face marked");
|
||||
let f = w.face_list()[0];
|
||||
mark_moved(w, &[f])?;
|
||||
assert!(moved_faces(w)?, "a face marked");
|
||||
assert!(!moved_faces(w)?, "the mark removed");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A draft leaves no mark of its own on what it returns, on a plane or on a cylinder turned cone.
|
||||
#[test]
|
||||
fn a_draft_leaves_no_mark() {
|
||||
let z = [0.0, 0.0, 1.0];
|
||||
let bx = make_box([0.0; 3], [20.0, 30.0, 10.0]).unwrap();
|
||||
let plane = draft(&bx, &[[10.0, 0.0, 5.0]], TOL, [0.0; 3], z, z, 0.1).unwrap();
|
||||
let cyl = make_cylinder([0.0; 3], 5.0, 10.0).unwrap();
|
||||
let cone = draft(&cyl, &[[5.0, 0.0, 5.0]], TOL, [0.0; 3], z, z, 0.1).unwrap();
|
||||
for b in [plane, cone] {
|
||||
super::with_body("marks", &b, |_, w| {
|
||||
assert!(!moved_faces(w)?);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// `moved_faces` on both kernels: drafts so small they leave a plane or cylinder where it was
|
||||
/// still moved a face (SMLib's mark and solidcore's both stay on it), an angle that brings a wall
|
||||
/// onto the plane of the wall above it leaves the two faces apart, and no draft here is
|
||||
/// SMLIB_ERR_NO_EFFECT on either: the same answer, volume and counts. The C++ check holds a local
|
||||
/// operation to what it promised and no body found so far trips it.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn a_draft_moves_a_face_as_on_cpp() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::sweeps::{Curve, DraftCorner};
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
let z = [0.0, 0.0, 1.0];
|
||||
let make = |h: ffi::Handle| Brep::made(h);
|
||||
let plate = make(ffi::make_box(&[0.0; 3], &[40.0, 40.0, 10.0]).unwrap());
|
||||
let rod = make(ffi::make_cylinder(&[20.0, 20.0, -5.0], 5.0, 30.0).unwrap());
|
||||
let holed = make(ffi::ops::boolean(plate.cpp().unwrap(), rod.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap());
|
||||
let pinned = make(ffi::ops::boolean(plate.cpp().unwrap(), rod.cpp().unwrap(), ffi::ops::BOOLEAN_UNION).unwrap());
|
||||
let t = 5f64.to_radians().tan();
|
||||
// A wall x = 20 up to z = 10, then a wall leaning in 5 degrees: the lower wall turned 5
|
||||
// degrees about z = 10 lies on the upper one's plane.
|
||||
let p = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 0.0, 10.0], [20.0 - 10.0 * t, 0.0, 20.0], [0.0, 0.0, 20.0]];
|
||||
let profile: Vec<Curve> = (0..5).map(|i| Curve::Line { start: p[i], end: p[(i + 1) % 5] }).collect();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let leaning = Brep::extrude(&profile, [0.0, 1.0, 0.0], 10.0, 0.0, DraftCorner::default(), true).unwrap();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
let five = 5f64.to_radians();
|
||||
let cases: [(&str, &Brep, [f64; 3], [f64; 3], f64); 7] = [
|
||||
("tiny on a plane", &plate, [20.0, 0.0, 5.0], [0.0; 3], 1e-14),
|
||||
("tiny on a hole", &holed, [25.0, 20.0, 5.0], [0.0, 0.0, 5.0], 1e-14),
|
||||
("tiny on a pin", &pinned, [25.0, 20.0, 20.0], [0.0, 0.0, 5.0], -1e-14),
|
||||
("small on a hole", &holed, [25.0, 20.0, 5.0], [0.0, 0.0, 5.0], 1e-6),
|
||||
("onto the wall above", &leaning, [20.0, 5.0, 5.0], [0.0, 0.0, 10.0], five),
|
||||
("away from the wall above", &leaning, [20.0, 5.0, 5.0], [0.0, 0.0, 10.0], -five),
|
||||
("hinged far off", &plate, [20.0, 0.0, 5.0], [0.0, 0.0, 1000.0], five),
|
||||
];
|
||||
for (name, b, at, origin, angle) in cases {
|
||||
let cpp = ffi::ops::draft(b.cpp().unwrap(), &[at], TOL, &origin, &z, &z, angle).unwrap();
|
||||
let (v, n) = (ffi::volume(&cpp, 1e-8).unwrap(), ffi::counts(&cpp).unwrap().map(|x| usize::try_from(x).unwrap()));
|
||||
let core = draft(b.core().unwrap(), &[at], TOL, origin, z, z, angle).unwrap();
|
||||
let w = volume(&core, 1e-8).unwrap();
|
||||
assert!(rel(w, v) < 1e-9, "{name}: solidcore {w} vs C++ {v}");
|
||||
assert_eq!(counts(&core).unwrap(), n, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The tip cut off a triangular prism, drafted about a plane 100 mm off so that the whole face goes
|
||||
/// out past where its neighbours meet: its edges vanish, `SM_ERR_OFFSET_EDGE_VANISHES` in SMLib,
|
||||
/// which the capi's `status` answers as `SMLIB_ERR_UNSUPPORTED`, on both kernels.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn a_draft_past_the_neighbours_meeting_is_unsupported_as_on_cpp() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::sweeps::{Curve, DraftCorner};
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
let p = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [11.0, 18.0, 0.0], [9.0, 18.0, 0.0]];
|
||||
let profile: Vec<Curve> = (0..4).map(|i| Curve::Line { start: p[i], end: p[(i + 1) % 4] }).collect();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let prism = Brep::extrude(&profile, [0.0, 0.0, 1.0], 10.0, 0.0, DraftCorner::default(), true).unwrap();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
let (tip, z, origin, angle) = ([[10.0, 18.0, 5.0]], [0.0, 0.0, 1.0], [0.0, 0.0, -100.0], (-2f64).to_radians());
|
||||
let cpp = ffi::ops::draft(prism.cpp().unwrap(), &tip, TOL, &origin, &z, &z, angle);
|
||||
assert_eq!(cpp.err(), Some((ffi::ops::ERR_UNSUPPORTED, None)));
|
||||
assert_eq!(draft(prism.core().unwrap(), &tip, TOL, origin, z, z, angle).err(), Some(Error::Unsupported));
|
||||
}
|
||||
|
||||
/// A flat L of two faces thickened by 2 with mitred walls: 2 x 10 x (20 + 22), as the C++ kernel
|
||||
/// makes it.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn an_l_sheet_thickens_as_on_cpp() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::sweeps::{Curve, DraftCorner};
|
||||
use crate::{Brep, ffi};
|
||||
crate::init().unwrap();
|
||||
let l = [Curve::Line { start: [0.0; 3], end: [20.0, 0.0, 0.0] }, Curve::Line { start: [20.0, 0.0, 0.0], end: [20.0, 20.0, 0.0] }];
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let sheet = Brep::extrude(&l, [0.0, 0.0, 1.0], 10.0, 0.0, DraftCorner::default(), false).unwrap();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
let cpp = ffi::ops::thicken(sheet.cpp().unwrap(), 2.0, false).unwrap();
|
||||
assert!(rel(ffi::volume(&cpp, 1e-8).unwrap(), 840.0) < 1e-9);
|
||||
let core = thicken(sheet.core().unwrap(), 2.0, false).unwrap();
|
||||
assert!(rel(volume(&core, 1e-8).unwrap(), 840.0) < 1e-9);
|
||||
}
|
||||
|
||||
/// Face and edge tags (`Brep::tagged`) through offsets on solidcore, as on C++: the box is
|
||||
/// tagged on the C++ kernel, its solidcore body converted from that and tagged the same way, and
|
||||
/// the two results' histories must be the same.
|
||||
#[cfg(feature = "native")]
|
||||
mod history {
|
||||
use crate::{Brep, ffi};
|
||||
|
||||
fn tagged_box() -> Brep {
|
||||
crate::init().unwrap();
|
||||
Brep::made(ffi::make_box(&[0.0; 3], &[20.0, 30.0, 10.0]).unwrap()).tagged(0).unwrap()
|
||||
}
|
||||
|
||||
/// The box hollowed to 2 mm walls, its top open: the five walls offset inward stand for the
|
||||
/// faces they offset.
|
||||
#[test]
|
||||
fn tags_cross_a_shell() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = tagged_box();
|
||||
let top = [[10.0, 15.0, 10.0]];
|
||||
let cpp = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::Shell, a.cpp().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::shell(a.core().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
/// The top moved down 2 mm: it stands for itself, its four neighbours are trimmed to meet it.
|
||||
#[test]
|
||||
fn tags_cross_a_face_offset() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = tagged_box();
|
||||
let top = [[10.0, 15.0, 10.0]];
|
||||
let cpp = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, a.cpp().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::offset_faces(a.core().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
/// A flat sheet and a half-round open extrusion, tagged on the C++ kernel.
|
||||
fn tagged_sheet(curved: bool) -> Brep {
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::sweeps::{Curve, DraftCorner};
|
||||
crate::init().unwrap();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let b = if curved {
|
||||
let arc = [Curve::Arc { center: [0.0; 3], axis: [0.0, 0.0, 1.0], reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: PI }];
|
||||
Brep::extrude(&arc, [0.0, 0.0, 1.0], 20.0, 0.0, DraftCorner::default(), false)
|
||||
} else {
|
||||
let c = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 10.0, 0.0], [0.0, 10.0, 0.0]];
|
||||
let l: Vec<Curve> = (0..4).map(|i| Curve::Line { start: c[i], end: c[(i + 1) % 4] }).collect();
|
||||
Brep::planar_faces(&l)
|
||||
};
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
b.unwrap().tagged(0).unwrap()
|
||||
}
|
||||
|
||||
/// A sheet thickened to one side and to both: both faces stand for the sheet's (the histories
|
||||
/// `offset_backend` reads), its edges and their offsets for its edges, the walls for nothing.
|
||||
fn thicken_parity(curved: bool) {
|
||||
for symmetric in [false, true] {
|
||||
let a = tagged_sheet(curved);
|
||||
let cpp = Brep::made(ffi::ops::thicken(a.cpp().unwrap(), 2.0, symmetric).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::thicken(a.core().unwrap(), 2.0, symmetric).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical(), "symmetric {symmetric}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_thicken_of_a_curved_sheet() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
thicken_parity(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_thicken_of_a_flat_sheet() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
thicken_parity(false);
|
||||
}
|
||||
|
||||
/// A sheet moved along its normal, flat and curved: each face stands for itself.
|
||||
#[test]
|
||||
fn tags_cross_a_sheet_offset() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
for curved in [false, true] {
|
||||
let a = tagged_sheet(curved);
|
||||
let cpp = Brep::made(ffi::sheets::offset_sheet(a.cpp().unwrap(), 1.5).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::offset_sheet(a.core().unwrap(), 1.5).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical(), "curved {curved}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A cylinder hollowed, its top open: the walls round closed edges, on a curved face.
|
||||
#[test]
|
||||
fn tags_cross_a_shell_of_a_cylinder() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::init().unwrap();
|
||||
let a = Brep::made(ffi::make_cylinder(&[0.0; 3], 10.0, 20.0).unwrap()).tagged(0).unwrap();
|
||||
let top = [[0.0, 0.0, 20.0]];
|
||||
let cpp = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::Shell, a.cpp().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::shell(a.core().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
/// A 5 degree draft of the front about the bottom plane: the front tilts, its neighbours'
|
||||
/// edges move with it.
|
||||
#[test]
|
||||
fn tags_cross_a_draft() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = tagged_box();
|
||||
let front = [[10.0, 0.0, 5.0]];
|
||||
let (origin, normal, pull, angle) = ([0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 5f64.to_radians());
|
||||
let cpp = ffi::ops::draft(a.cpp().unwrap(), &front, super::TOL, &origin, &normal, &pull, angle).unwrap();
|
||||
let cpp = Brep::made(cpp).history().unwrap();
|
||||
let core = Brep::made_core(super::draft(a.core().unwrap(), &front, super::TOL, origin, normal, pull, angle).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
}
|
||||
|
||||
/// A stepped block, 40 x 30 x 10 with its half x < 20 raised to 20 (rig case
|
||||
/// shell/cov-replace-face-step-onto-the-higher-face, the fork's `offset_faces_close_a_step_wall`):
|
||||
/// a top moved onto the other's plane closes the step wall between them to nothing, and the tops
|
||||
/// are one face of a 6-faced box.
|
||||
#[test]
|
||||
fn offset_faces_close_a_step_wall() {
|
||||
use super::boolean::boolean;
|
||||
let stepped = boolean(&make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), &make_box([0.0; 3], [20.0, 30.0, 20.0]).unwrap(), BooleanOp::Union)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(counts(&stepped).unwrap()[0], 8);
|
||||
for (at, d, v, a) in [([30.0, 15.0, 10.0], 10.0, 24000.0, 5200.0), ([10.0, 15.0, 20.0], -10.0, 12000.0, 3800.0)] {
|
||||
let r = offset_faces(&stepped, &[at], 0.1, d).unwrap();
|
||||
let (got, n, area) = (volume(&r, 1e-8).unwrap(), counts(&r).unwrap(), super::area(&r, 1e-8).unwrap());
|
||||
assert_eq!(n[0], 6, "d {d}");
|
||||
assert!(rel(got, v) < 1e-9 && rel(area, a) < 1e-9, "d {d}: V {got} A {area}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rig case shell/4492's rounded box: four mitred rounds, r 3, come off a 40 x 30 x 10 box's top
|
||||
/// together and follow it. From height 0 the outline is the 34 x 24 top moved out by the round's
|
||||
/// w(z) = sqrt(9 - (z - axis)^2), or 3 below the axis. With the fork's values at +19, and bodies
|
||||
/// whose rounds go all the way down from -7 and are cut short from -7.5 (solidcore#2027). At -9
|
||||
/// solidcore refuses to put the rounds back (solidcore#2020, open): SolidCraft says it does not
|
||||
/// make that move, where the C++ kernel gives the analytic value, also checked here. With SMLib
|
||||
/// built, the C++ kernel gives the same at every other move.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn mitred_rounds_follow_a_moved_top() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
mitred_rounds_follow_a_moved_top_check();
|
||||
}
|
||||
|
||||
/// The same on a build without the C++ kernel: solidcore's half alone. A test of its own, so that
|
||||
/// tests/KNOWN_FAILING_TESTS's entry for the C++ comparison (mitred_rounds_follow_a_moved_top) is graded only where it runs.
|
||||
#[cfg(not(feature = "native"))]
|
||||
#[test]
|
||||
fn mitred_rounds_follow_a_moved_top_on_solidcore() {
|
||||
mitred_rounds_follow_a_moved_top_check();
|
||||
}
|
||||
|
||||
fn mitred_rounds_follow_a_moved_top_check() {
|
||||
use super::blend::{Corners, blend};
|
||||
use crate::Blend;
|
||||
let edges = [[20.0, 0.0, 10.0], [20.0, 30.0, 10.0], [0.0, 15.0, 10.0], [40.0, 15.0, 10.0]];
|
||||
let top = [20.0, 15.0, 10.0];
|
||||
let rounded = blend(&make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), Blend::Round, &edges, &[3.0; 4], TOL, &[], &[], Corners::Filled).unwrap();
|
||||
let analytic = |d: f64| {
|
||||
let (height, axis, n) = (10.0 + d, 7.0 + d, 20000);
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let z = height * (f64::from(i) + 0.5) / f64::from(n);
|
||||
let w = if z < axis { 3.0 } else { (9.0 - (z - axis) * (z - axis)).max(0.0).sqrt() };
|
||||
(34.0 + 2.0 * w) * (24.0 + 2.0 * w) * height / f64::from(n)
|
||||
})
|
||||
.sum::<f64>()
|
||||
};
|
||||
let moves = [
|
||||
(19.0, [10, 20, 12]),
|
||||
(1.0, [10, 20, 12]),
|
||||
(-1.0, [10, 20, 12]),
|
||||
(-6.9, [10, 20, 12]),
|
||||
(-7.0, [6, 12, 8]),
|
||||
(-7.5, [6, 12, 8]),
|
||||
(-8.0, [6, 12, 8]),
|
||||
(-9.5, [6, 12, 8]),
|
||||
];
|
||||
for (d, n) in moves {
|
||||
let moved = offset_faces(&rounded, &[top], 0.1, d).unwrap();
|
||||
let v = volume(&moved, 1e-8).unwrap();
|
||||
assert_eq!(counts(&moved).unwrap(), n, "d {d}");
|
||||
assert!(rel(v, analytic(d)) < 1e-6, "d {d}: {v} vs {}", analytic(d));
|
||||
}
|
||||
assert!(matches!(offset_faces(&rounded, &[top], 0.1, -9.0), Err(Error::Unsupported)));
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
use crate::ffi;
|
||||
crate::init().unwrap();
|
||||
let b = ffi::make_box(&[0.0; 3], &[40.0, 30.0, 10.0]).unwrap();
|
||||
let round = ffi::ops::blend(ffi::ops::BLEND_ROUND, &b, &edges, &[3.0; 4], TOL).unwrap();
|
||||
for (d, _) in moves {
|
||||
let cpp = ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, &round, &[top], 0.1, d).unwrap();
|
||||
let ours = offset_faces(&rounded, &[top], 0.1, d).unwrap();
|
||||
let (o, c) = (volume(&ours, 1e-8).unwrap(), ffi::volume(&cpp, 1e-8).unwrap());
|
||||
// Below -7 the rounds are cut short: both are within 1e-6 of the analytic value, and at
|
||||
// -7.5 within 4.7e-9 of each other.
|
||||
let agree = if d < -7.0 { 1e-8 } else { 1e-9 };
|
||||
assert!(rel(o, c) < agree, "d {d}: {o} vs the C++ kernel's {c}");
|
||||
}
|
||||
// At -9 the C++ kernel at 26cc451 refuses it too.
|
||||
assert!(ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, &round, &[top], 0.1, -9.0).is_err(), "d -9");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Split Face and Split Body on solidcore: the C API's composition, ported from the fork's
|
||||
//! `capi/src/smlib_split.cpp` (`smlib_split_faces`, `smlib_split_body`, `cut_face`,
|
||||
//! `plane_covers`, `slice`, `both_sides`, `smlib_split_body_by_sheet`) and the tool surfaces of `capi/src/smlib_ssi.cpp`
|
||||
//! (`smlib_surface_plane`, `_cylinder`, `_cone`, `_sphere`, `_torus`, `_nurbs`).
|
||||
//!
|
||||
//! As there, every operation works on copies of its body (`SmApiBrepCopy`). A face is cut where
|
||||
//! a tool meets it the way SMLib's plane cutter cuts a subset of faces: the face's surface is
|
||||
//! intersected with the tool over the face's UV domain (`CreatePlanarSectionCurves` for a plane
|
||||
//! reaching over the face, `GlobalSurfaceIntersect` otherwise) and the untrimmed curves go to
|
||||
//! `SmBrep::MergeCurvesOnSurface` with the tool as the intersecting surface. Unlike the plane
|
||||
//! cutter, a failed merge fails the call. A body is split by slicing (`SM_BO_SLICE`) copies of it
|
||||
//! with a one-face sheet on the tool surface, once with the surface turned over; or with a sheet
|
||||
//! of several faces as it is, its surfaces first turned to agree.
|
||||
//!
|
||||
//! A tool is a [`crate::ssi::Surface`]; solidcore's own surface is made from its description
|
||||
//! ([`Spec`]), as `smlib_ssi.cpp` makes SMLib's, over the surface's natural UV domain.
|
||||
|
||||
use std::sync::PoisonError;
|
||||
|
||||
use solidcore::EntityRef;
|
||||
use solidcore::boolean::api_brep::boolean_with_options;
|
||||
use solidcore::boolean::merge::MergeOptions;
|
||||
use solidcore::geom::a_object::BooleanOperation;
|
||||
use solidcore::geom::axis2_placement::Axis2Placement;
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::object::ObjectRef;
|
||||
use solidcore::geom::tol::{USE_DEFAULT, approx_tol3d};
|
||||
use solidcore::geom::vector2d::Vec2;
|
||||
use solidcore::geom::vector3d::{Point3, Vec3};
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::nurbs::b_spline_curve::p1::KnotType;
|
||||
use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
|
||||
use solidcore::nurbs::math::CPoint;
|
||||
use solidcore::query::brep::BrepFaceServices;
|
||||
use solidcore::query::face::p1::calculate_bounding_box;
|
||||
use solidcore::surface::cone::p1::Cone;
|
||||
use solidcore::surface::plane::p1::Plane;
|
||||
use solidcore::surface::sphere::{Sphere, SphereOptions};
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::surface::surface::p2::TsectCurveType;
|
||||
use solidcore::surface::torus::{Torus, TorusOptions};
|
||||
use solidcore::topology::brep::p1 as brep_p1;
|
||||
use solidcore::topology::brep::p2::{is_manifold_solid, is_sheet};
|
||||
use solidcore::topology::brep::p3::{new_brep, with_kernel_callbacks};
|
||||
use solidcore::topology::brep::p8::create_face_from_surface;
|
||||
use solidcore::topology::brep::p9::orient_sheet_surfaces;
|
||||
use solidcore::topology::brep::p10::merge_curves_on_surface_through_surface;
|
||||
use solidcore::topology::brep::planar_section::face_planar_section_curves;
|
||||
use solidcore::topology::brep_cache::CacheObject;
|
||||
use solidcore::topology::edge::NotifyEnv;
|
||||
use solidcore::topology::face::p2 as face_p2;
|
||||
use solidcore::topology::{Brep, EdgeId, FaceId};
|
||||
|
||||
use super::blend::status;
|
||||
use super::sweep::build_faces;
|
||||
use super::{Body, run};
|
||||
use crate::ssi::Spec;
|
||||
use crate::{Error, Result};
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
|
||||
/// `SMLIB_SPLIT_MAX_FACES`, `SMLIB_SPLIT_MAX_TOOLS`.
|
||||
pub(crate) const MAX_FACES: usize = crate::ffi::split::MAX_FACES;
|
||||
pub(crate) const MAX_TOOLS: usize = crate::ffi::split::MAX_TOOLS;
|
||||
|
||||
/// `SM_ERR`.
|
||||
const SM_ERR: i32 = 1001;
|
||||
|
||||
/// The capi's statuses as SolidCraft errors: an operation's own result.
|
||||
type Out<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A tool surface as solidcore holds it: the surface over its natural UV domain (`smlib_surface`'s
|
||||
/// `s` and `domain`), and for a plane its point and unit normal (`IsPlanar`).
|
||||
pub(crate) struct Tool {
|
||||
pub(super) surface: Surface,
|
||||
pub(super) domain: Extent2d,
|
||||
plane: Option<(Point3, Vec3)>,
|
||||
}
|
||||
|
||||
fn v3(p: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(p[0], p[1], p[2])
|
||||
}
|
||||
|
||||
/// The runner's `unit_cross`.
|
||||
fn unit_cross(a: Vec3, b: Vec3) -> Vec3 {
|
||||
let c = a.cross(b);
|
||||
let l = c.length();
|
||||
Vec3::new(c.x / l, c.y / l, c.z / l)
|
||||
}
|
||||
|
||||
/// `[k0, k0, k1, ...]` as unique values and their multiplicities (`unique_knots`).
|
||||
fn unique_knots(k: &[f64]) -> (Vec<f64>, Vec<usize>) {
|
||||
let (mut values, mut mults): (Vec<f64>, Vec<usize>) = (Vec::new(), Vec::new());
|
||||
for &x in k {
|
||||
match (values.last(), mults.last_mut()) {
|
||||
(Some(&last), Some(m)) if last == x => *m += 1,
|
||||
_ => {
|
||||
values.push(x);
|
||||
mults.push(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
(values, mults)
|
||||
}
|
||||
|
||||
/// `clamped`: the first and the last knot each p + 1 times.
|
||||
fn clamped(k: &[f64], p: usize) -> bool {
|
||||
match (k.first(), k.get(p), k.last(), k.len().checked_sub(1 + p).and_then(|i| k.get(i))) {
|
||||
(Some(a), Some(b), Some(c), Some(d)) => a == b && c == d,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's surface as `smlib_ssi.cpp`'s constructors make it, then its natural UV domain
|
||||
/// (`construct`: finite and not empty, else `SM_ERR_INVALID_INPUT`).
|
||||
pub(super) fn tool(k: &Kernel, spec: &Spec) -> solidcore::Result<Out<Tool>> {
|
||||
let mut plane = None;
|
||||
let surface = match spec {
|
||||
Spec::Plane { origin, xaxis, yaxis, u, v } => {
|
||||
let dom = Extent2d::from_bounds(u[0], v[0], u[1], v[1]);
|
||||
plane = Some((v3(*origin), unit_cross(v3(*xaxis), v3(*yaxis))));
|
||||
Surface::from(Plane::new(v3(*origin), v3(*xaxis), v3(*yaxis), Vec2::new(1.0, 1.0), &dom))
|
||||
}
|
||||
Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => {
|
||||
let (x, y) = (v3(*xaxis), v3(*yaxis));
|
||||
let o = v3(*origin) + unit_cross(x, y) * height[0];
|
||||
Surface::from(Cone::new_cylinder_bounded(o, x, y, *radius, angle_deg[0], angle_deg[1], height[1] - height[0], false, false, false))
|
||||
}
|
||||
Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => {
|
||||
let (x, y) = (v3(*xaxis), v3(*yaxis));
|
||||
let (h0, h1) = (height[0], height[1]);
|
||||
let t = half_angle_deg.to_radians().tan();
|
||||
let o = v3(*apex) + unit_cross(x, y) * h0;
|
||||
let pl = Axis2Placement::new(o, x, y);
|
||||
Surface::from(Cone::create_canonical(k, &pl, h0 * t, h1 * t, h1 - h0, false, false)?)
|
||||
}
|
||||
Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => {
|
||||
let anal = Extent2d::from_bounds(lon_deg[0], lat_deg[0], lon_deg[1], lat_deg[1]);
|
||||
let opts = SphereOptions { swap_uv: false, inside_out: false, circle_nurb: None };
|
||||
Surface::from(Sphere::new(k, v3(*center), v3(*xaxis), v3(*yaxis), anal, *radius, opts)?)
|
||||
}
|
||||
Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => {
|
||||
let anal = Extent2d::from_bounds(major_deg[0], minor_deg[0], major_deg[1], minor_deg[1]);
|
||||
Surface::from(Torus::new(k, v3(*center), v3(*xaxis), v3(*yaxis), anal, *major_radius, *minor_radius, TorusOptions::default())?)
|
||||
}
|
||||
Spec::Nurbs { degree, nu, nv, knots_u, knots_v, points, weights } => {
|
||||
let [p, q] = *degree;
|
||||
let w = |i: usize| weights.as_ref().and_then(|w| w.get(i).copied()).unwrap_or(1.0);
|
||||
if !clamped(knots_u, p) || !clamped(knots_v, q) {
|
||||
// SMLib's component constructor clamps the surface to its domain.
|
||||
let (ku, mu) = unique_knots(knots_u);
|
||||
let (kv, mv) = unique_knots(knots_v);
|
||||
let wts: Vec<f64> = (0..points.len()).map(w).collect();
|
||||
let pts: Vec<Point3> = points.iter().map(|p| v3(*p)).collect();
|
||||
let data = CanonicalSurfaceData {
|
||||
u_degree: p,
|
||||
v_degree: q,
|
||||
control_points: &pts,
|
||||
form: BSplineSurfaceForm::Unspecified,
|
||||
u_multiplicities: &mu,
|
||||
v_multiplicities: &mv,
|
||||
u_knots: &ku,
|
||||
v_knots: &kv,
|
||||
knot_type: KnotType::Unspecified,
|
||||
weights: Some(&wts),
|
||||
uv_domain: None,
|
||||
};
|
||||
Surface::from(BSplineSurface::create_canonical(&data)?)
|
||||
} else {
|
||||
// The runner's way: the homogeneous net filled directly.
|
||||
let mut pw = Vec::with_capacity(*nu);
|
||||
for i in 0..*nu {
|
||||
let mut row = Vec::with_capacity(*nv);
|
||||
for j in 0..*nv {
|
||||
let at = i * nv + j;
|
||||
let (Some(pt), wt) = (points.get(at), w(at)) else {
|
||||
return Ok(Err(Error::Argument));
|
||||
};
|
||||
row.push(CPoint::new(pt[0] * wt, pt[1] * wt, pt[2] * wt, wt));
|
||||
}
|
||||
pw.push(row);
|
||||
}
|
||||
let (n, m) = (nu.saturating_sub(1), nv.saturating_sub(1));
|
||||
let (r, s) = (knots_u.len().saturating_sub(1), knots_v.len().saturating_sub(1));
|
||||
Surface::from(BSplineSurface::from_data(n, m, p, q, r, s, &pw, knots_u, knots_v)?)
|
||||
}
|
||||
}
|
||||
};
|
||||
let domain = surface.natural_uv_domain_with_nurb(k)?;
|
||||
let (lo, hi) = (domain.min(), domain.max());
|
||||
if !(lo.x.is_finite() && lo.y.is_finite() && hi.x.is_finite() && hi.y.is_finite() && lo.x < hi.x && lo.y < hi.y) {
|
||||
return Ok(Err(Error::Status(1007)));
|
||||
}
|
||||
Ok(Ok(Tool { surface, domain, plane }))
|
||||
}
|
||||
|
||||
/// `plane_covers`: whether the plane `tool` reaches over all of `bx` (each corner's foot on the
|
||||
/// plane lies in the tool's parameter box): then its section with a face in the box is the
|
||||
/// infinite plane's.
|
||||
fn plane_covers(k: &Kernel, tool: &Tool, bx: &solidcore::geom::extent3d::Extent3d, tol: f64) -> bool {
|
||||
let (lo, hi) = (tool.domain.min(), tool.domain.max());
|
||||
let at = |u: f64, v: f64| tool.surface.evaluate_point(k, Vec2::new(u, v)).ok();
|
||||
let (Some(o), Some(pu), Some(pv)) = (at(lo.x, lo.y), at(hi.x, lo.y), at(lo.x, hi.y)) else {
|
||||
return false;
|
||||
};
|
||||
// The plane is affine in (u, v): P = o + (u - u0) X + (v - v0) Y.
|
||||
let x = (pu - o) / (hi.x - lo.x);
|
||||
let y = (pv - o) / (hi.y - lo.y);
|
||||
let (a, b, c) = (x.dot(x), x.dot(y), y.dot(y));
|
||||
let det = a * c - b * b;
|
||||
if !(det.abs() > 0.0) {
|
||||
return false;
|
||||
}
|
||||
let (m, mm) = (bx.min(), bx.max());
|
||||
(0..8).all(|kk| {
|
||||
let q = Vec3::new(if kk & 1 != 0 { mm.x } else { m.x }, if kk & 2 != 0 { mm.y } else { m.y }, if kk & 4 != 0 { mm.z } else { m.z });
|
||||
let r = q - o;
|
||||
let (g0, g1) = (x.dot(r), y.dot(r));
|
||||
let u = lo.x + (c * g0 - b * g1) / det;
|
||||
let v = lo.y + (a * g1 - b * g0) / det;
|
||||
let (su, sv) = (tol / a.sqrt(), tol / c.sqrt());
|
||||
u >= lo.x - su && u <= hi.x + su && v >= lo.y - sv && v <= hi.y + sv
|
||||
})
|
||||
}
|
||||
|
||||
/// The name of each edge curve a merge makes (`SmApiCut`'s).
|
||||
fn curve_ref(e: EdgeId, _type_code: i64) -> ObjectRef {
|
||||
solidcore::topology::loopuse::notify_ref(EntityRef::Edge(e))
|
||||
}
|
||||
|
||||
/// `cut_face`: cuts `face` where `tool` meets it; the faces it was split into besides itself.
|
||||
fn cut_face(k: &mut Kernel, b: &mut Brep, env: &mut NotifyEnv<'_>, face: FaceId, tool: &Tool, tol: f64) -> solidcore::Result<Out<Vec<FaceId>>> {
|
||||
let Some(surface) = face_p2::surface(b, face)? else {
|
||||
return Ok(Err(Error::Status(SM_ERR)));
|
||||
};
|
||||
let surface = surface.clone_for_evaluation()?;
|
||||
let domain = face_p2::uv_domain(b, face)?;
|
||||
let approx = approx_tol3d(k, approx_tol3d(k, tol) / 4.0);
|
||||
let bx = match calculate_bounding_box(k, &mut *b, face, false) {
|
||||
Ok(bx) => bx,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
let merge =
|
||||
|k: &mut Kernel, b: &mut Brep, env: &mut NotifyEnv<'_>, mut c3: Vec<_>, uv: Option<Vec<_>>| match merge_curves_on_surface_through_surface(
|
||||
k,
|
||||
b,
|
||||
env,
|
||||
&curve_ref,
|
||||
face,
|
||||
tol,
|
||||
&mut c3,
|
||||
uv.as_deref(),
|
||||
Some(tool.surface.clone()),
|
||||
) {
|
||||
Ok((made, _)) => Ok(Ok(made)),
|
||||
Err(e) => Ok(Err(status(e))),
|
||||
};
|
||||
// A plane reaching over the face sections it exactly (SmPlaneCutter's way); a plane strip,
|
||||
// like any other tool, is intersected over its own domain.
|
||||
if let Some((at, normal)) = tool.plane
|
||||
&& plane_covers(k, tool, &bx, tol)
|
||||
{
|
||||
// As SmPlaneCutter: a face far from the plane is not sectioned.
|
||||
if bx.distance_to_plane(at, normal).abs() > tol {
|
||||
return Ok(Ok(Vec::new()));
|
||||
}
|
||||
let section = match face_planar_section_curves(k, b, face, &surface, &domain, at, normal, Some(approx), None) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
if section.curves_3d.is_empty() {
|
||||
return Ok(Ok(Vec::new()));
|
||||
}
|
||||
let uv = (section.uv_curves.len() == section.curves_3d.len()).then_some(section.uv_curves);
|
||||
return merge(k, b, env, section.curves_3d, uv);
|
||||
}
|
||||
// `face->GetSurface()` reaches its face's trimmed cache: the solve runs through the B-rep's
|
||||
// face services, as solidcore's own face intersections do (a face-owned surface has no cache
|
||||
// of its own).
|
||||
let mut services = BrepFaceServices::new(&mut *b);
|
||||
let found = match surface.global_surface_intersect_with_context(
|
||||
k,
|
||||
[Some(CacheObject::Surface(face)), None],
|
||||
&mut services,
|
||||
&domain,
|
||||
&tool.surface,
|
||||
&tool.domain,
|
||||
[true, true],
|
||||
Some(approx),
|
||||
None,
|
||||
) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
// A single point of contact cuts nothing.
|
||||
let (mut c3, mut uv1) = (Vec::new(), Vec::new());
|
||||
for (i, c) in found.curves_3d.into_iter().enumerate() {
|
||||
if found.curve_types.get(i) == Some(&TsectCurveType::Touching) {
|
||||
continue;
|
||||
}
|
||||
c3.push(c);
|
||||
uv1.push(found.surface1_uv_curves.get(i).cloned().flatten());
|
||||
}
|
||||
if c3.is_empty() {
|
||||
return Ok(Ok(Vec::new()));
|
||||
}
|
||||
let all_uv = uv1.iter().all(Option::is_some);
|
||||
merge(k, b, env, c3, all_uv.then_some(uv1))
|
||||
}
|
||||
|
||||
/// `smlib_split_faces`: a copy of `body` with faces `faces` (indices in its face order, distinct)
|
||||
/// cut where each tool meets them, the tools in turn, each cutting the pieces the earlier ones
|
||||
/// left; and for each face how many faces it became besides itself. `None` when no face is split
|
||||
/// (`SMLIB_ERR_NO_CUT`).
|
||||
pub(crate) fn split_faces(body: &Body, faces: &[usize], tools: &[&Spec]) -> Result<Option<(Body, Vec<usize>)>> {
|
||||
if faces.is_empty() || faces.len() > MAX_FACES || tools.is_empty() || tools.len() > MAX_TOOLS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if body.0.is_poisoned() {
|
||||
return Err(Error::Internal("smlib_split_faces: the body was left unfinished by an earlier panic".to_owned()));
|
||||
}
|
||||
let r = run("smlib_split_faces", |k| {
|
||||
let mut a = {
|
||||
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
super::dump::input(k, "brep", &g);
|
||||
let count = brep_p1::faces(&g, None)?.len();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if faces.iter().any(|&f| f >= count || !seen.insert(f)) {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
brep_copy(k, &g)?
|
||||
};
|
||||
let all = brep_p1::faces(&a, None)?;
|
||||
// Each chosen face and, as tools cut it, its pieces.
|
||||
let mut pieces: Vec<Vec<FaceId>> = Vec::with_capacity(faces.len());
|
||||
for &f in faces {
|
||||
let Some(&face) = all.get(f) else { return Ok(Err(Error::Status(SM_ERR))) };
|
||||
pieces.push(vec![face]);
|
||||
}
|
||||
let mut made = vec![0usize; faces.len()];
|
||||
let mut built = Vec::with_capacity(tools.len());
|
||||
for spec in tools {
|
||||
match tool(k, spec)? {
|
||||
Ok(t) => built.push(t),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
}
|
||||
let editing = a.editing_enabled();
|
||||
let doing = k.context.doing_boolean();
|
||||
a.set_editing_enabled(true);
|
||||
k.context.set_doing_boolean(true);
|
||||
let tol = a.tolerance();
|
||||
// The edits notify through the kernel's callbacks with the body's own attributes, so
|
||||
// the history tags follow each cut (as SMLib's objects notify their attributes).
|
||||
let cut = with_kernel_callbacks(k, &mut a, |k, a, env| {
|
||||
let mut total = 0usize;
|
||||
for t in &built {
|
||||
for (i, list) in pieces.iter_mut().enumerate() {
|
||||
let now = list.clone();
|
||||
for f in now {
|
||||
let split = match cut_face(k, a, env, f, t, tol)? {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
if let Some(m) = made.get_mut(i) {
|
||||
*m += split.len();
|
||||
}
|
||||
total += split.len();
|
||||
list.extend(split);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ok(total))
|
||||
});
|
||||
a.set_editing_enabled(editing);
|
||||
k.context.set_doing_boolean(doing);
|
||||
a.refresh_cached_topology();
|
||||
// The cut's new edges own their curves under the kernel's identities (it records them),
|
||||
// not the copy's Notify ones, and the check reads the owners by the body's naming.
|
||||
a.set_naming(solidcore::topology::NamingScheme::Caller);
|
||||
Ok(match cut? {
|
||||
Err(e) => Err(e),
|
||||
Ok(0) => Ok(None),
|
||||
Ok(_) => Ok(Some((a, made))),
|
||||
})
|
||||
})??;
|
||||
Ok(r.map(|(b, made)| (Body::new(b), made)))
|
||||
}
|
||||
|
||||
/// `slice`: a copy of `body` sliced by `sheet` (SMLib's slice reads the side from the surfaces, not
|
||||
/// the faces' uses). `None` when nothing is left.
|
||||
fn slice(k: &mut Kernel, body: &Body, sheet: Brep) -> solidcore::Result<Out<Option<Brep>>> {
|
||||
let a = {
|
||||
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
brep_copy(k, &g)?
|
||||
};
|
||||
let options = MergeOptions::new(false, false, false, false, false);
|
||||
let r = match boolean_with_options(k, a, sheet, BooleanOperation::Slice, options) {
|
||||
Ok(r) => r.result,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
Ok(Ok((!r.face_list().is_empty()).then_some(r)))
|
||||
}
|
||||
|
||||
/// `both_sides`: both parts, `side` making each side's sheet (`true`: turned over, for the back).
|
||||
/// A body that is no manifold solid is [`Error::NotSolid`]; a side left empty is `None`
|
||||
/// (`SMLIB_ERR_NO_CUT`), and with `whole` so is a part that is no manifold solid: `SM_BO_SLICE`
|
||||
/// with a sheet that only nicks a region behaves as a difference, leaving the sheet's piece as a
|
||||
/// slit in the body and a lamina on the other side, which is no cut.
|
||||
fn both_sides(
|
||||
k: &mut Kernel,
|
||||
body: &Body,
|
||||
whole: bool,
|
||||
mut side: impl FnMut(&mut Kernel, bool) -> solidcore::Result<Out<Brep>>,
|
||||
) -> solidcore::Result<Out<Option<(Brep, Brep)>>> {
|
||||
let solid = {
|
||||
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
is_manifold_solid(&g)?
|
||||
};
|
||||
if !solid {
|
||||
return Ok(Err(Error::NotSolid));
|
||||
}
|
||||
let mut parts = [None, None];
|
||||
for (part, flip) in parts.iter_mut().zip([true, false]) {
|
||||
let sheet = match side(k, flip)? {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
*part = match slice(k, body, sheet)? {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
}
|
||||
let [Some(back), Some(front)] = parts else { return Ok(Ok(None)) };
|
||||
if whole && !(is_manifold_solid(&back)? && is_manifold_solid(&front)?) {
|
||||
return Ok(Ok(None));
|
||||
}
|
||||
Ok(Ok(Some((back, front))))
|
||||
}
|
||||
|
||||
/// One side of `smlib_split_body`: a one-face sheet on the tool's surface, turned over with `flip`.
|
||||
fn side(k: &mut Kernel, body: &Body, spec: &Spec, flip: bool) -> solidcore::Result<Out<Brep>> {
|
||||
let tolerance = body.0.lock().unwrap_or_else(PoisonError::into_inner).tolerance();
|
||||
let t = match tool(k, spec)? {
|
||||
Ok(t) => t,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let mut surface = t.surface.copy(k)?;
|
||||
if flip && let Err(e) = surface.swap_uv(k) {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
let mut sheet = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
sheet.set_tolerance(tolerance);
|
||||
let natural = surface.natural_uv_domain_with_nurb(k)?;
|
||||
if let Err(e) = build_faces(&mut sheet, |s, fenv| create_face_from_surface(k, s, Some(surface), &natural, fenv)) {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
sheet.refresh_cached_topology();
|
||||
Ok(Ok(sheet))
|
||||
}
|
||||
|
||||
/// `smlib_split_body`: the manifold solid `body` cut in two by the tool: the part behind the
|
||||
/// surface (its normal points away) and the part in front. A side left empty is `None`
|
||||
/// (`SMLIB_ERR_NO_CUT`); a body that is no manifold solid is [`Error::NotSolid`].
|
||||
pub(crate) fn split_body(body: &Body, spec: &Spec) -> Result<Option<(Body, Body)>> {
|
||||
if body.0.is_poisoned() {
|
||||
return Err(Error::Internal("smlib_split_body: the body was left unfinished by an earlier panic".to_owned()));
|
||||
}
|
||||
let r = run("smlib_split_body", |k| {
|
||||
super::dump::input(k, "brep", &body.0.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
both_sides(k, body, false, |k, flip| side(k, body, spec, flip))
|
||||
})??;
|
||||
Ok(r.map(|(b, f)| (Body::new(b), Body::new(f))))
|
||||
}
|
||||
|
||||
/// `smlib_split_body_by_sheet`: the manifold solid `body` cut in two by `sheet` as it is (one or
|
||||
/// more faces joined edge to edge), its surfaces turned to agree with face `face`'s
|
||||
/// (`SmBrep::OrientSheetSurfaces`), for the back all turned over: the part behind that normal and
|
||||
/// the part in front. A sheet that does not divide the body is `None` (`SMLIB_ERR_NO_CUT`). A
|
||||
/// `sheet` that is no sheet, or a `face` out of range, is [`Error::Argument`].
|
||||
pub(crate) fn split_body_by_sheet(body: &Body, sheet: &Body, face: usize) -> Result<Option<(Body, Body)>> {
|
||||
if std::ptr::eq(body, sheet) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if body.0.is_poisoned() || sheet.0.is_poisoned() {
|
||||
return Err(Error::Internal("smlib_split_body_by_sheet: a body was left unfinished by an earlier panic".to_owned()));
|
||||
}
|
||||
let r = run("smlib_split_body_by_sheet", |k| {
|
||||
super::dump::input(k, "brep", &body.0.lock().unwrap_or_else(PoisonError::into_inner));
|
||||
{
|
||||
let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
super::dump::input(k, "sheet", &g);
|
||||
if !is_sheet(k, &g)? || face >= g.face_list().len() {
|
||||
return Ok(Err(Error::Argument));
|
||||
}
|
||||
}
|
||||
both_sides(k, body, true, |k, flip| {
|
||||
let mut copy = {
|
||||
let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
brep_copy(k, &g)?
|
||||
};
|
||||
let Some(&f) = brep_p1::faces(©, None)?.get(face) else { return Ok(Err(Error::Argument)) };
|
||||
// The UV swaps notify as SMLib's objects do: through the kernel's callbacks, with
|
||||
// the sheet's own attributes, so its faces' tags see each swap.
|
||||
let turned = with_kernel_callbacks(k, &mut copy, |k, b, env| orient_sheet_surfaces(k, b, Some(f), !flip, env));
|
||||
copy.refresh_cached_topology();
|
||||
Ok(match turned {
|
||||
Ok(()) => Ok(copy),
|
||||
Err(e) => Err(status(e)),
|
||||
})
|
||||
})
|
||||
})??;
|
||||
Ok(r.map(|(b, f)| (Body::new(b), Body::new(f))))
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Split Face and Split Body on solidcore, against closed forms: face, edge and vertex counts,
|
||||
//! volumes, how many faces each chosen face became, and every result exports to a record that
|
||||
//! validates. With SMLib built, the same input converted to the C++ kernel and the same tools
|
||||
//! give the same result or the same error (parity only: the expected values are the analytic
|
||||
//! ones).
|
||||
//!
|
||||
//! The body is a 10 mm cube [0, 10]^3 on solidcore; the tools are described as
|
||||
//! [`crate::ssi::Surface`] describes them.
|
||||
|
||||
use super::split::{split_body, split_body_by_sheet, split_faces};
|
||||
use super::{Body, area, counts, make_box, to_arrays, volume};
|
||||
use crate::Error;
|
||||
use crate::export::native;
|
||||
use crate::ssi::Spec;
|
||||
|
||||
const X: [f64; 3] = [1.0, 0.0, 0.0];
|
||||
const Y: [f64; 3] = [0.0, 1.0, 0.0];
|
||||
const Z: [f64; 3] = [0.0, 0.0, 1.0];
|
||||
|
||||
fn cube() -> Body {
|
||||
make_box([0.0; 3], [10.0; 3]).unwrap()
|
||||
}
|
||||
|
||||
/// The plane x = `at` over u (along y) and v (along z) in `[-1, 11]`: it reaches over the cube.
|
||||
fn plane_x(at: f64) -> Spec {
|
||||
Spec::Plane { origin: [at, 0.0, 0.0], xaxis: Y, yaxis: Z, u: [-1.0, 11.0], v: [-1.0, 11.0] }
|
||||
}
|
||||
|
||||
/// The cylinder of radius 3 about the vertical line through (5, 5), from z = -1 to 11.
|
||||
fn cylinder() -> Spec {
|
||||
Spec::Cylinder { origin: [5.0, 5.0, 0.0], xaxis: X, yaxis: Y, radius: 3.0, angle_deg: [0.0, 360.0], height: [-1.0, 11.0] }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Op {
|
||||
/// Split the faces (all six when empty) by the tools.
|
||||
Faces(Vec<usize>, Vec<Spec>),
|
||||
Body(Spec),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Expect {
|
||||
/// Volume, counts, and the total of faces made.
|
||||
Split(f64, [usize; 3], usize),
|
||||
/// The two sides' volumes and counts.
|
||||
Halves(f64, [usize; 3], f64, [usize; 3]),
|
||||
NoCut,
|
||||
}
|
||||
|
||||
fn case(name: &str) -> (Op, Expect) {
|
||||
let all: Vec<usize> = (0..6).collect();
|
||||
match name {
|
||||
// Four faces cross x = 5, each cut in two; the four edges along x are cut at it.
|
||||
"split_faces_by_a_plane" => (Op::Faces(all, vec![plane_x(5.0)]), Expect::Split(1000.0, [10, 20, 12], 4)),
|
||||
// The cylinder meets the top and the bottom in a circle each: a disc and a holed face.
|
||||
"split_faces_by_a_cylinder" => (Op::Faces(all, vec![cylinder()]), Expect::Split(1000.0, [8, 14, 10], 2)),
|
||||
// Two planes in turn, the second cutting the first's pieces.
|
||||
"split_faces_by_two_planes" => (Op::Faces(all, vec![plane_x(3.0), plane_x(7.0)]), Expect::Split(1000.0, [14, 28, 16], 8)),
|
||||
"split_faces_missed_by_the_tool" => (Op::Faces(all, vec![plane_x(20.0)]), Expect::NoCut),
|
||||
"split_body_by_a_plane" => (Op::Body(plane_x(3.0)), Expect::Halves(300.0, [6, 12, 8], 700.0, [6, 12, 8])),
|
||||
// A tool that misses leaves both parts the whole body.
|
||||
"split_body_missed_by_the_tool" => (Op::Body(plane_x(20.0)), Expect::Halves(1000.0, [6, 12, 8], 1000.0, [6, 12, 8])),
|
||||
_ => panic!("no case {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Measured {
|
||||
Split { volume: f64, counts: [usize; 3], made: Vec<usize> },
|
||||
Halves { volumes: [f64; 2], counts: [[usize; 3]; 2] },
|
||||
NoCut,
|
||||
}
|
||||
|
||||
type Got = std::result::Result<Measured, Error>;
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
fn valid(name: &str, b: &Body) {
|
||||
let (rec, _) = native(&to_arrays(b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
}
|
||||
|
||||
fn judge(name: &str, who: &str, got: Got) -> Got {
|
||||
let (_, expect) = case(name);
|
||||
match (&expect, &got) {
|
||||
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(Expect::NoCut, Ok(m)) => assert_eq!(*m, Measured::NoCut, "{name}: {who}"),
|
||||
(Expect::Split(v, n, total), Ok(Measured::Split { volume, counts, made })) => {
|
||||
assert!(rel(*volume, *v) < 1e-9, "{name}: {who} volume {volume} vs analytic {v}");
|
||||
assert_eq!(counts, n, "{name}: {who} counts");
|
||||
assert_eq!(made.iter().sum::<usize>(), *total, "{name}: {who} faces made {made:?}");
|
||||
}
|
||||
(Expect::Halves(v0, n0, v1, n1), Ok(Measured::Halves { volumes, counts })) => {
|
||||
assert!(rel(volumes[0], *v0) < 1e-9 && rel(volumes[1], *v1) < 1e-9, "{name}: {who} volumes {volumes:?} vs {v0} {v1}");
|
||||
assert_eq!(counts, &[*n0, *n1], "{name}: {who} counts");
|
||||
}
|
||||
(e, Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn solidcore_case(name: &str) -> Got {
|
||||
let (op, _) = case(name);
|
||||
let got = match &op {
|
||||
Op::Faces(faces, tools) => {
|
||||
let tools: Vec<&Spec> = tools.iter().collect();
|
||||
split_faces(&cube(), faces, &tools).map(|r| match r {
|
||||
None => Measured::NoCut,
|
||||
Some((b, made)) => {
|
||||
valid(name, &b);
|
||||
Measured::Split { volume: volume(&b, 1e-8).unwrap(), counts: counts(&b).unwrap(), made }
|
||||
}
|
||||
})
|
||||
}
|
||||
Op::Body(tool) => split_body(&cube(), tool).map(|r| match r {
|
||||
None => Measured::NoCut,
|
||||
Some((b, f)) => {
|
||||
valid(name, &b);
|
||||
valid(name, &f);
|
||||
Measured::Halves {
|
||||
volumes: [volume(&b, 1e-8).unwrap(), volume(&f, 1e-8).unwrap()],
|
||||
counts: [counts(&b).unwrap(), counts(&f).unwrap()],
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
judge(name, "solidcore", got)
|
||||
}
|
||||
|
||||
/// The C++ kernel's surface for `spec`.
|
||||
#[cfg(feature = "native")]
|
||||
fn surface(spec: &Spec) -> crate::ssi::Surface {
|
||||
use crate::ssi::Surface;
|
||||
match spec {
|
||||
Spec::Plane { origin, xaxis, yaxis, u, v } => Surface::plane(*origin, *xaxis, *yaxis, *u, *v),
|
||||
Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => {
|
||||
Surface::cylinder(*origin, *xaxis, *yaxis, *radius, *angle_deg, *height)
|
||||
}
|
||||
_ => panic!("no such tool in these cases"),
|
||||
}
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, called directly (whatever the choice), on the cube converted
|
||||
/// from solidcore's.
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Got {
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let (op, _) = case(name);
|
||||
let body = Brep::made_core(cube());
|
||||
let measure = |h: &ffi::Handle| {
|
||||
let (rec, _) = native(&Brep::made(ffi::copy(h).unwrap()).cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
(ffi::volume(h, 1e-8).unwrap(), ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap()))
|
||||
};
|
||||
let got = match &op {
|
||||
Op::Faces(faces, tools) => {
|
||||
let made: Vec<crate::ssi::Surface> = tools.iter().map(surface).collect();
|
||||
let handles: Vec<&ffi::ssi::SurfaceHandle> = made.iter().map(|s| s.handle().unwrap()).collect();
|
||||
let faces: Vec<i64> = faces.iter().map(|&f| f as i64).collect();
|
||||
match ffi::split::split_faces(body.cpp().unwrap(), &faces, &handles) {
|
||||
Ok((h, made)) => {
|
||||
let (volume, counts) = measure(&h);
|
||||
Ok(Measured::Split { volume, counts, made: made.into_iter().map(|m| usize::try_from(m).unwrap()).collect() })
|
||||
}
|
||||
Err(ffi::ops::ERR_NO_CUT) => Ok(Measured::NoCut),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
}
|
||||
Op::Body(tool) => match ffi::split::split_body(body.cpp().unwrap(), surface(tool).handle().unwrap()) {
|
||||
Ok((b, f)) => {
|
||||
let (vb, nb) = measure(&b);
|
||||
let (vf, nf) = measure(&f);
|
||||
Ok(Measured::Halves { volumes: [vb, vf], counts: [nb, nf] })
|
||||
}
|
||||
Err(ffi::ops::ERR_NO_CUT) => Ok(Measured::NoCut),
|
||||
Err(s) => Err(error(s)),
|
||||
},
|
||||
};
|
||||
judge(name, "C++", got)
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &str) {
|
||||
match (solidcore_case(name), cpp_case(name)) {
|
||||
(Ok(Measured::Split { volume: a, counts: na, made: ma }), Ok(Measured::Split { volume: b, counts: nb, made: mb })) => {
|
||||
assert!(rel(a, b) < 1e-9, "{name}: solidcore {a} vs C++ {b}");
|
||||
assert_eq!(na, nb, "{name}: counts (solidcore vs C++)");
|
||||
assert_eq!(ma, mb, "{name}: faces made per face (solidcore vs C++)");
|
||||
}
|
||||
(Ok(Measured::Halves { volumes: a, counts: na }), Ok(Measured::Halves { volumes: b, counts: nb })) => {
|
||||
assert!(rel(a[0], b[0]) < 1e-9 && rel(a[1], b[1]) < 1e-9, "{name}: solidcore {a:?} vs C++ {b:?}");
|
||||
assert_eq!(na, nb, "{name}: counts (solidcore vs C++)");
|
||||
}
|
||||
(a, b) => assert_eq!(a, b, "{name}: solidcore vs C++"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::cpp_case(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
cases!(
|
||||
split_faces_by_a_plane,
|
||||
split_faces_by_a_cylinder,
|
||||
split_faces_by_two_planes,
|
||||
split_faces_missed_by_the_tool,
|
||||
split_body_by_a_plane,
|
||||
split_body_missed_by_the_tool,
|
||||
);
|
||||
|
||||
/// The capi's argument checks, before any kernel call.
|
||||
#[test]
|
||||
fn hostile_arguments_are_refused() {
|
||||
let b = cube();
|
||||
let p = plane_x(5.0);
|
||||
assert_eq!(split_faces(&b, &[], &[&p]).err(), Some(Error::Argument), "no face");
|
||||
assert_eq!(split_faces(&b, &[0], &[]).err(), Some(Error::Argument), "no tool");
|
||||
assert_eq!(split_faces(&b, &[0, 0], &[&p]).err(), Some(Error::Argument), "a face twice");
|
||||
assert_eq!(split_faces(&b, &[6], &[&p]).err(), Some(Error::Argument), "no face 6");
|
||||
assert_eq!(split_faces(&b, &[usize::MAX], &[&p]).err(), Some(Error::Argument), "no face usize::MAX");
|
||||
let many = vec![&p; super::split::MAX_TOOLS + 1];
|
||||
assert_eq!(split_faces(&b, &[0], &many).err(), Some(Error::Argument), "too many tools");
|
||||
let faces: Vec<usize> = (0..=super::split::MAX_FACES).collect();
|
||||
assert_eq!(split_faces(&b, &faces, &[&p]).err(), Some(Error::Argument), "too many faces");
|
||||
}
|
||||
|
||||
/// A sheet is no solid to split in two (`SMLIB_ERR_NOT_SOLID`).
|
||||
#[test]
|
||||
fn a_sheet_is_not_split_into_two_bodies() {
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::primitive::api_curves::create_line_segment;
|
||||
use solidcore::sweep::api_brep::create_linear_sweep;
|
||||
let sheet = super::run("sheet", |k| {
|
||||
let c = Curve::from(create_line_segment(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0))?);
|
||||
create_linear_sweep(k, &[Some(&c)], Vec3::new(0.0, 1.0, 0.0), 10.0, false)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap();
|
||||
assert!(rel(area(&sheet, 1e-8).unwrap(), 100.0) < 1e-9);
|
||||
assert_eq!(split_body(&sheet, &plane_x(5.0)).err(), Some(Error::NotSolid));
|
||||
}
|
||||
|
||||
/// Faces of the box -20..5 x -20..20 x -20..5 copied off it into a sheet (`CopyFaces` with merge,
|
||||
/// as the capi's test): the top z = 5 and, with `side`, the side x = 5 too.
|
||||
fn lid(side: bool) -> Body {
|
||||
use solidcore::EntityRef;
|
||||
use solidcore::geom::tol::USE_DEFAULT;
|
||||
use solidcore::query::api_queries::face_internal_point;
|
||||
use solidcore::topology::brep::p1::faces;
|
||||
use solidcore::topology::brep::p2::ArenaNotifyState;
|
||||
use solidcore::topology::brep::p3::new_brep;
|
||||
use solidcore::topology::brep::p7::copy_faces;
|
||||
let slab = make_box([-20.0; 3], [25.0, 40.0, 25.0]).unwrap();
|
||||
super::run("sheet", |k| {
|
||||
let mut whole = solidcore::topology::brep::p3::brep_copy(k, &slab.0.lock().unwrap())?;
|
||||
let mut picked = Vec::new();
|
||||
for f in faces(&whole, None)? {
|
||||
let p = face_internal_point(k, &mut whole, f)?;
|
||||
if (p.z - 5.0).abs() < 1e-9 || (side && (p.x - 5.0).abs() < 1e-9) {
|
||||
picked.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(picked.len(), if side { 2 } else { 1 });
|
||||
let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
w.set_tolerance(whole.tolerance());
|
||||
{
|
||||
let mut state = ArenaNotifyState::new(&w);
|
||||
let mut env = state.env(None);
|
||||
let key_of = env.key_of;
|
||||
let this_face = |f| key_of(EntityRef::Face(f));
|
||||
copy_faces(k, &mut whole, &picked, &mut w, true, &this_face, &mut env)?;
|
||||
}
|
||||
w.refresh_cached_topology();
|
||||
Ok(w)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The capi's test of `smlib_split_body_by_sheet` (fork 2fc375f): a cube 0..10 cut by an L of two
|
||||
/// faces: the corner x < 5, z < 5 (250) on one side, the rest (750) on the other, whichever face
|
||||
/// the sides are read from; on C++ too.
|
||||
#[test]
|
||||
fn split_body_by_an_l_sheet() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let (c, sheet) = (cube(), lid(true));
|
||||
for face in 0..2 {
|
||||
let (b, f) = split_body_by_sheet(&c, &sheet, face).unwrap().expect("the sheet divides the cube");
|
||||
valid("L sheet", &b);
|
||||
valid("L sheet", &f);
|
||||
let (vb, vf) = (volume(&b, 1e-8).unwrap(), volume(&f, 1e-8).unwrap());
|
||||
assert!(rel(vb.min(vf), 250.0) < 1e-9 && rel(vb.max(vf), 750.0) < 1e-9, "face {face}: {vb} + {vf}");
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
let (body, tool) = (crate::Brep::made_core(cube()), crate::Brep::made_core(lid(true)));
|
||||
let (bc, fc) = crate::ffi::split::split_body_by_sheet(body.cpp().unwrap(), tool.cpp().unwrap(), face as i64).unwrap();
|
||||
let (vbc, vfc) = (crate::ffi::volume(&bc, 1e-8).unwrap(), crate::ffi::volume(&fc, 1e-8).unwrap());
|
||||
assert!(rel(vb, vbc) < 1e-9 && rel(vf, vfc) < 1e-9, "face {face}: {vb} + {vf} vs C++ {vbc} + {vfc}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `smlib_split_body_by_sheet` refuses: a body that is no sheet, a face out of range, the
|
||||
/// sheet as its own body; a face that ends inside the cube only nicks it, which is no cut.
|
||||
#[test]
|
||||
fn split_by_a_sheet_refuses() {
|
||||
let (c, sheet) = (cube(), lid(true));
|
||||
assert_eq!(split_body_by_sheet(&c, &make_box([-20.0; 3], [25.0, 40.0, 25.0]).unwrap(), 0).err(), Some(Error::Argument));
|
||||
assert_eq!(split_body_by_sheet(&c, &sheet, 2).err(), Some(Error::Argument));
|
||||
assert_eq!(split_body_by_sheet(&c, &c, 0).err(), Some(Error::Argument));
|
||||
assert!(split_body_by_sheet(&c, &lid(false), 0).unwrap().is_none());
|
||||
assert_eq!(split_body_by_sheet(&sheet, &lid(false), 0).err(), Some(Error::NotSolid));
|
||||
}
|
||||
|
||||
/// The body split is never changed.
|
||||
#[test]
|
||||
fn the_body_stays_as_it_was() {
|
||||
let b = cube();
|
||||
let _ = split_faces(&b, &[0, 1, 2, 3, 4, 5], &[&plane_x(5.0)]).unwrap();
|
||||
let _ = split_body(&b, &plane_x(5.0)).unwrap();
|
||||
assert_eq!(counts(&b).unwrap(), [6, 12, 8]);
|
||||
assert!(rel(volume(&b, 1e-8).unwrap(), 1000.0) < 1e-12);
|
||||
}
|
||||
|
||||
/// Face and edge tags (`Brep::tagged`) through a face split on solidcore, as on C++.
|
||||
#[cfg(feature = "native")]
|
||||
mod history {
|
||||
use crate::{Brep, ffi};
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_face_split() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::init().unwrap();
|
||||
let a = Brep::made_core(super::cube()).tagged(0).unwrap();
|
||||
let p = super::plane_x(5.0);
|
||||
let s = super::surface(&p);
|
||||
let faces = [0i64, 1, 2, 3, 4, 5];
|
||||
let cpp = Brep::made(ffi::split::split_faces(a.cpp().unwrap(), &faces, &[s.handle().unwrap()]).unwrap().0).history().unwrap();
|
||||
let core = super::split_faces(a.core().unwrap(), &[0, 1, 2, 3, 4, 5], &[&p]).unwrap().unwrap().0;
|
||||
let core = Brep::made_core(core).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-face sheet on a B-spline surface (as SolidCraft's extrude of a sketch spline makes it:
|
||||
/// the cubic through (0, 0), (13, -8), (27, 8), (40, 0) in x and z, straight along y over
|
||||
/// [0, 40]) cut by four plane strips standing on the square [12, 28]^2: the strips meet the
|
||||
/// face inside its bounds, so the face's surface is intersected through its face's trimmed
|
||||
/// cache. The square comes out as a face of its own, the sheet holed by it.
|
||||
#[test]
|
||||
fn a_spline_sheet_is_cut_by_strips_inside_it() {
|
||||
use solidcore::geom::tol::USE_DEFAULT;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::nurbs::b_spline_curve::p1::KnotType;
|
||||
use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::topology::brep::p3::new_brep;
|
||||
use solidcore::topology::brep::p8::create_face_from_surface;
|
||||
// Control points u-major: the curve's four, each at y = 0 and y = 40.
|
||||
let curve = [[0.0, 0.0], [13.0, -8.0], [27.0, 8.0], [40.0, 0.0]];
|
||||
let pts: Vec<Vec3> = curve.iter().flat_map(|[x, z]| [Vec3::new(*x, 0.0, *z), Vec3::new(*x, 40.0, *z)]).collect();
|
||||
let sheet = super::run("sheet", |k| {
|
||||
let surface = Surface::from(BSplineSurface::create_canonical(&CanonicalSurfaceData {
|
||||
u_degree: 3,
|
||||
v_degree: 1,
|
||||
control_points: &pts,
|
||||
form: BSplineSurfaceForm::Unspecified,
|
||||
u_multiplicities: &[4, 4],
|
||||
v_multiplicities: &[2, 2],
|
||||
u_knots: &[0.0, 1.0],
|
||||
v_knots: &[0.0, 40.0],
|
||||
knot_type: KnotType::Unspecified,
|
||||
weights: None,
|
||||
uv_domain: None,
|
||||
})?);
|
||||
let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
let natural = surface.natural_uv_domain_with_nurb(k)?;
|
||||
super::sweep::build_faces(&mut b, |s, fenv| create_face_from_surface(k, s, Some(surface), &natural, fenv))?;
|
||||
b.refresh_cached_topology();
|
||||
Ok(b)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap();
|
||||
assert_eq!(counts(&sheet).unwrap(), [1, 4, 4]);
|
||||
let whole = area(&sheet, 1e-8).unwrap();
|
||||
// The strip standing on the line from `o` along `d` (16 long), reaching past the sheet in z.
|
||||
let strip = |o: [f64; 3], d: [f64; 3]| Spec::Plane { origin: o, xaxis: Z, yaxis: d, u: [-60.0, 60.0], v: [0.0, 16.0] };
|
||||
let tools = [
|
||||
strip([12.0, 12.0, 0.0], X),
|
||||
strip([28.0, 12.0, 0.0], Y),
|
||||
strip([28.0, 28.0, 0.0], [-1.0, 0.0, 0.0]),
|
||||
strip([12.0, 28.0, 0.0], [0.0, -1.0, 0.0]),
|
||||
];
|
||||
let tools: Vec<&Spec> = tools.iter().collect();
|
||||
let (cut, made) = split_faces(&sheet, &[0], &tools).unwrap().expect("the strips cut the sheet");
|
||||
valid("spline sheet", &cut);
|
||||
// The square's face and the holed sheet: four edges and vertices round each.
|
||||
assert_eq!(counts(&cut).unwrap(), [2, 8, 8]);
|
||||
assert_eq!(made, [1]);
|
||||
// Cutting moves no surface: the pieces cover the sheet.
|
||||
assert!(rel(area(&cut, 1e-8).unwrap(), whole) < 1e-9);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Surface/surface intersection on solidcore (`smlib_ssi.cpp`'s `smlib_ssi`, `smlib_surface_domain`,
|
||||
//! `smlib_surface_evaluate`), on the surfaces [`crate::core::split`] makes from a [`Spec`]:
|
||||
//! `SmSurface::GlobalSurfaceIntersect` and `SmAdvSurfaceIntersector`, whose results are read as the
|
||||
//! capi reads them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::geom::vector2d::Point2;
|
||||
use solidcore::intersect::surface_intersector::p5::do_intersection;
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::nurbs::b_spline_curve::BSplineCurve;
|
||||
use solidcore::surface::surface::p2::{SurfaceIntersection, TsectCurveType};
|
||||
|
||||
use super::convert::bspline_curve_part;
|
||||
use super::run;
|
||||
use super::split::{Tool, tool};
|
||||
use crate::ffi::ssi::Nurbs;
|
||||
use crate::ssi::{CurveType, Method, Spec, SsiCurve, SsiPoint, SsiResult, curve2, curve3};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// The surface of `spec` with its natural UV domain (`construct`).
|
||||
pub(crate) fn surface(spec: &Spec) -> Result<Tool> {
|
||||
run("smlib_surface", |k| tool(k, spec))?
|
||||
}
|
||||
|
||||
/// `[u0, u1, v0, v1]`.
|
||||
pub(crate) fn domain(s: &Tool) -> [f64; 4] {
|
||||
let (lo, hi) = (s.domain.min(), s.domain.max());
|
||||
[lo.x, hi.x, lo.y, hi.y]
|
||||
}
|
||||
|
||||
/// The surface's point at (u, v): `smlib_surface_evaluate`, a non-finite point being `SM_ERR`.
|
||||
pub(crate) fn evaluate(s: &Tool, u: f64, v: f64) -> Result<[f64; 3]> {
|
||||
if !u.is_finite() || !v.is_finite() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let p = run("smlib_surface_evaluate", |k| s.surface.evaluate_point(k, Point2::new(u, v)))?;
|
||||
let r = [p.x, p.y, p.z];
|
||||
if r.iter().all(|x| x.is_finite()) { Ok(r) } else { Err(Error::Solidcore("smlib_surface_evaluate: not a finite point".into())) }
|
||||
}
|
||||
|
||||
fn curve_type(t: TsectCurveType) -> CurveType {
|
||||
match t {
|
||||
TsectCurveType::Unknown => CurveType::Unknown,
|
||||
TsectCurveType::Touching => CurveType::Touching,
|
||||
TsectCurveType::Crossing => CurveType::Crossing,
|
||||
TsectCurveType::Tangent => CurveType::Tangent,
|
||||
TsectCurveType::Coincident => CurveType::Coincident,
|
||||
TsectCurveType::NearTangent => CurveType::NearTangent,
|
||||
TsectCurveType::RegionBoundary => CurveType::RegionBoundary,
|
||||
}
|
||||
}
|
||||
|
||||
/// The capi's `nurbs`: `c` as its B-spline, control points made cartesian in `dim` coordinates;
|
||||
/// `None` for a curve that is not one, which is never approximated here.
|
||||
fn nurbs(k: &Kernel, c: &Curve, dim: usize) -> Result<Option<Nurbs>> {
|
||||
let Some(part): Option<Cow<'_, BSplineCurve>> = bspline_curve_part(k, c)? else { return Ok(None) };
|
||||
let Some(n) = part.gw_nurb() else { return Ok(None) };
|
||||
let mut out = Nurbs { points: Vec::with_capacity(n.cpts().len() * dim), weights: Vec::with_capacity(n.cpts().len()), knots: n.knots().to_vec() };
|
||||
for p in n.cpts() {
|
||||
let w = if p.w != solidcore::nurbs::math::NOW { p.w } else { 1.0 };
|
||||
let q = [p.x / w, p.y / w, p.z / w];
|
||||
out.points.extend_from_slice(q.get(..dim).unwrap_or(&q));
|
||||
out.weights.push(w);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// The capi's `start`: where `c` starts, NaN where there is no curve or it can't be evaluated.
|
||||
fn start(k: &Kernel, c: Option<&Curve>) -> [f64; 3] {
|
||||
c.and_then(|c| c.evaluate_point(k, c.natural_interval().min()).ok()).map_or([f64::NAN; 3], |p| [p.x, p.y, p.z])
|
||||
}
|
||||
|
||||
fn read(k: &Kernel, r: &SurfaceIntersection) -> Result<SsiResult> {
|
||||
let mut out = SsiResult::default();
|
||||
for (i, c3) in r.curves_3d.iter().enumerate() {
|
||||
let (ua, ub) = (r.surface1_uv_curves.get(i).and_then(Option::as_ref), r.surface2_uv_curves.get(i).and_then(Option::as_ref));
|
||||
let ty = r.curve_types.get(i).copied();
|
||||
let ivl = c3.natural_interval();
|
||||
let uv = |c: Option<&Curve>| -> Result<_> { Ok(c.map(|c| nurbs(k, c, 2)).transpose()?.flatten().map(curve2)) };
|
||||
out.curves.push(SsiCurve {
|
||||
curve_type: ty.map_or(CurveType::None, curve_type),
|
||||
deviation: r.deviations.get(i).copied().unwrap_or(f64::NAN),
|
||||
interval: [ivl.min(), ivl.max()],
|
||||
// A curve that is no B-spline is the one thing the result can't hold.
|
||||
curve: curve3(nurbs(k, c3, 3)?.ok_or(Error::Unsupported)?),
|
||||
uv_a: uv(ua)?,
|
||||
uv_b: uv(ub)?,
|
||||
});
|
||||
if ty == Some(TsectCurveType::Touching) {
|
||||
let (a, b) = (start(k, ua), start(k, ub));
|
||||
out.points.push(SsiPoint { xyz: start(k, Some(c3)), uv_a: [a[0], a[1]], uv_b: [b[0], b[1]] });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `smlib_ssi`: `a` and `b` within their domains, at approximation tolerance `approx_tol` (<= 0:
|
||||
/// the kernel's default).
|
||||
pub(crate) fn intersect(a: &Tool, b: &Tool, method: Method, approx_tol: f64) -> Result<SsiResult> {
|
||||
if !approx_tol.is_finite() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let tol = (approx_tol > 0.0).then_some(approx_tol);
|
||||
let edges = [true, true];
|
||||
run("smlib_ssi", |k| {
|
||||
let found = match method {
|
||||
Method::Global => a.surface.global_surface_intersect(k, &a.domain, &b.surface, &b.domain, edges, tol, None),
|
||||
Method::Advanced => do_intersection(k, &a.surface, &a.domain, &b.surface, &b.domain, edges, tol, None),
|
||||
};
|
||||
// An SMLib status is an answer, as the C API returns it.
|
||||
match found {
|
||||
Ok(found) => Ok(read(k, &found)),
|
||||
Err(e) => match e.sm_status() {
|
||||
Some(status) => Ok(Err(Error::Status(status))),
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
})?
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,768 @@
|
||||
//! Extrusions, revolutions, sweeps, lofts and planar faces on solidcore, against closed forms:
|
||||
//! every result exports to a record that validates. With SMLib built, the same case on the C++
|
||||
//! kernel gives the same volume (or, for a sheet, area), counts and faces by surface kind, or the
|
||||
//! same error (parity only: the expected values are the analytic ones).
|
||||
//!
|
||||
//! The profiles are built straight into solidcore curves from the curve lists.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::sweep::{extrude, face_section, loft, loft_to_point, planar_faces, project_curves, revolve, screw_sweep, sweep_placed, sweep_stations};
|
||||
use super::{Body, area, bounds, counts, make_box, make_cylinder, to_arrays, volume};
|
||||
use crate::Error;
|
||||
use crate::export::native;
|
||||
use crate::sweeps::{Affine, CornerRefusal, Curve, DraftCorner, Ends, SweepFrame};
|
||||
|
||||
/// A case longer than this is a hang.
|
||||
const TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
const Z: [f64; 3] = [0.0, 0.0, 1.0];
|
||||
|
||||
fn line(a: [f64; 3], b: [f64; 3]) -> Curve {
|
||||
Curve::Line { start: a, end: b }
|
||||
}
|
||||
|
||||
/// The closed polygon through `pts`.
|
||||
fn polygon(pts: &[[f64; 3]]) -> Vec<Curve> {
|
||||
(0..pts.len()).map(|i| line(pts[i], pts[(i + 1) % pts.len()])).collect()
|
||||
}
|
||||
|
||||
/// The square of side `s` from `(x, y)` in the plane z = `z`.
|
||||
fn square(x: f64, y: f64, s: f64, z: f64) -> Vec<Curve> {
|
||||
polygon(&[[x, y, z], [x + s, y, z], [x + s, y + s, z], [x, y + s, z]])
|
||||
}
|
||||
|
||||
/// The circle of `radius` about `axis` through `center`, from `reference`.
|
||||
fn circle(center: [f64; 3], axis: [f64; 3], reference: [f64; 3], radius: f64) -> Curve {
|
||||
Curve::Arc { center, axis, reference, radius, start: 0.0, end: TAU }
|
||||
}
|
||||
|
||||
/// An arc of radius 5 about the z axis from `reference`, from `start` to `end`.
|
||||
fn arc5(reference: [f64; 3], start: f64, end: f64) -> Curve {
|
||||
Curve::Arc { center: [0.0; 3], axis: Z, reference, radius: 5.0, start, end }
|
||||
}
|
||||
|
||||
/// The circle of radius 5 about the z axis as two half arcs in one frame.
|
||||
fn halves() -> Vec<Curve> {
|
||||
vec![arc5([1.0, 0.0, 0.0], 0.0, PI), arc5([1.0, 0.0, 0.0], PI, TAU)]
|
||||
}
|
||||
|
||||
/// The circle of radius 5 about the z axis as a quarter from +x and three quarters from +y.
|
||||
fn two_frames() -> Vec<Curve> {
|
||||
vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([0.0, 1.0, 0.0], 0.0, 3.0 * FRAC_PI_2)]
|
||||
}
|
||||
|
||||
/// The upper half disc of radius 5 about the z axis, its arc in two quarters.
|
||||
fn half_disc() -> Vec<Curve> {
|
||||
vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([1.0, 0.0, 0.0], FRAC_PI_2, PI), line([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0])]
|
||||
}
|
||||
|
||||
/// The rectangle from x = 5 to 8 and z = 0 to 4 in the plane y = 0: area 12, centroid at x 6.5.
|
||||
fn ring_section() -> Vec<Curve> {
|
||||
polygon(&[[5.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 0.0, 4.0], [5.0, 0.0, 4.0]])
|
||||
}
|
||||
|
||||
fn translate(t: [f64; 3]) -> Affine {
|
||||
Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t }
|
||||
}
|
||||
|
||||
fn scale_up(s: f64, z: f64) -> Affine {
|
||||
Affine { m: [[s, 0.0, 0.0], [0.0, s, 0.0], [0.0, 0.0, 1.0]], t: [0.0, 0.0, z] }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Op {
|
||||
PlanarFaces(Vec<Curve>),
|
||||
Extrude(Vec<Curve>, [f64; 3], f64, f64, DraftCorner, bool),
|
||||
Revolve(Vec<Curve>, [f64; 3], [f64; 3], f64, bool),
|
||||
Sweep(Vec<Curve>, Vec<Curve>, SweepFrame, bool),
|
||||
Loft(Vec<Vec<Curve>>, u32, bool),
|
||||
LoftToPoint(Vec<Curve>, [f64; 3]),
|
||||
Stations(Vec<Curve>, Vec<Affine>, Ends),
|
||||
Screw(Vec<Curve>, [f64; 3], [f64; 3], f64, f64, f64),
|
||||
/// A screw sweep with a radial law.
|
||||
ScrewRadial(Vec<Curve>, [f64; 3], [f64; 3], f64, f64, Vec<(f64, f64)>, f64),
|
||||
}
|
||||
|
||||
/// What a case must give.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Expect {
|
||||
/// A solid of this volume (relative 1e-9).
|
||||
Volume(f64),
|
||||
/// A solid of this volume within this relative error (walls approximated within a tolerance).
|
||||
About(f64, f64),
|
||||
/// A sheet of this area (relative 1e-9).
|
||||
Sheet(f64),
|
||||
/// A drafted corner refused for this reason, wherever.
|
||||
Corner(CornerRefusal),
|
||||
}
|
||||
|
||||
struct Case {
|
||||
op: Op,
|
||||
expect: Expect,
|
||||
}
|
||||
|
||||
/// The frustum of a pyramid or cone: h/3 (A1 + A2 + sqrt(A1 A2)).
|
||||
fn frustum(a1: f64, a2: f64, h: f64) -> f64 {
|
||||
h / 3.0 * (a1 + a2 + (a1 * a2).sqrt())
|
||||
}
|
||||
|
||||
fn case(name: &str) -> Case {
|
||||
let c = |op, expect| Case { op, expect };
|
||||
let rect = polygon(&[[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [30.0, 20.0, 0.0], [0.0, 20.0, 0.0]]);
|
||||
let theta = 5f64.to_radians();
|
||||
let disc = || vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 5.0)];
|
||||
match name {
|
||||
// A square of 10 with a hole of radius 2: 100 - 4π.
|
||||
"planar_faces_with_a_hole" => {
|
||||
let mut loops = square(0.0, 0.0, 10.0, 0.0);
|
||||
loops.push(circle([5.0, 5.0, 0.0], Z, [1.0, 0.0, 0.0], 2.0));
|
||||
c(Op::PlanarFaces(loops), Expect::Sheet(100.0 - 4.0 * PI))
|
||||
}
|
||||
// V = A h.
|
||||
"extrude_rectangle" => c(Op::Extrude(rect, Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(6000.0)),
|
||||
// V = π r² h.
|
||||
"extrude_circle" => c(Op::Extrude(disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)),
|
||||
// A circle drawn in two halves (split where another sketch curve ends on it), the
|
||||
// pieces in the circle's own frame: one wall, as the whole circle sweeps (smlib 7dee48f).
|
||||
"extrude_circle_in_two_halves" => c(Op::Extrude(halves(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)),
|
||||
// The same circle as a quarter and three quarters each in its own frame: one wall still,
|
||||
// its seam in the middle of the longer piece.
|
||||
"extrude_circle_in_two_frames" => c(Op::Extrude(two_frames(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)),
|
||||
// A half disc whose arc is drawn in two quarters, closed by its diameter: the quarters
|
||||
// sweep one wall. V = π r² h / 2.
|
||||
"extrude_half_disc_split_arc" => c(Op::Extrude(half_disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(125.0 * PI)),
|
||||
// Leaning in by θ, the walls' offsets overlap at the corners and are trimmed where they
|
||||
// cross, under every rule: the frustum from side 20 to 20 - 2 h tanθ.
|
||||
"extrude_square_drafted_round" => {
|
||||
let top = 20.0 - 2.0 * 10.0 * theta.tan();
|
||||
c(Op::Extrude(square(0.0, 0.0, 20.0, 0.0), Z, 10.0, theta, DraftCorner::Round, true), Expect::Volume(frustum(400.0, top * top, 10.0)))
|
||||
}
|
||||
"extrude_square_drafted_curvature" => {
|
||||
let top = 20.0 - 2.0 * 10.0 * theta.tan();
|
||||
c(Op::Extrude(square(0.0, 0.0, 20.0, 0.0), Z, 10.0, theta, DraftCorner::Curvature, true), Expect::Volume(frustum(400.0, top * top, 10.0)))
|
||||
}
|
||||
// No corner: the cone frustum from radius 5 to 5 - h tanθ.
|
||||
"extrude_circle_drafted" => {
|
||||
let r2 = 5.0 - 10.0 * theta.tan();
|
||||
c(Op::Extrude(disc(), Z, 10.0, theta, DraftCorner::default(), true), Expect::Volume(frustum(PI * 25.0, PI * r2 * r2, 10.0)))
|
||||
}
|
||||
// A strip 2 wide leaning in 10° over 10: the long walls meet across the short ones
|
||||
// (10 tan 10° > 1) before the far end.
|
||||
"extrude_draft_wall_closes" => c(
|
||||
Op::Extrude(
|
||||
polygon(&[[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 2.0, 0.0], [0.0, 2.0, 0.0]]),
|
||||
Z,
|
||||
10.0,
|
||||
10f64.to_radians(),
|
||||
DraftCorner::Curvature,
|
||||
true,
|
||||
),
|
||||
Expect::Corner(CornerRefusal::WallCloses),
|
||||
),
|
||||
// Pappus: A · θ x̄ = 12 · θ · 6.5.
|
||||
"revolve_rectangle_full" => c(Op::Revolve(ring_section(), [0.0; 3], Z, TAU, true), Expect::Volume(12.0 * TAU * 6.5)),
|
||||
"revolve_rectangle_quarter" => c(Op::Revolve(ring_section(), [0.0; 3], Z, FRAC_PI_2, true), Expect::Volume(12.0 * FRAC_PI_2 * 6.5)),
|
||||
// π r² L, along two lines end to end.
|
||||
"sweep_circle_straight" => c(
|
||||
Op::Sweep(
|
||||
vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 2.0)],
|
||||
vec![line([0.0; 3], [0.0, 0.0, 6.0]), line([0.0, 0.0, 6.0], [0.0, 0.0, 10.0])],
|
||||
SweepFrame::Follow,
|
||||
true,
|
||||
),
|
||||
Expect::Volume(PI * 4.0 * 10.0),
|
||||
),
|
||||
// A quarter of a torus: π r² · R θ.
|
||||
"sweep_circle_along_an_arc" => c(
|
||||
Op::Sweep(
|
||||
vec![circle([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0)],
|
||||
vec![Curve::Arc { center: [0.0; 3], axis: Z, reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }],
|
||||
SweepFrame::Follow,
|
||||
true,
|
||||
),
|
||||
Expect::Volume(PI * 10.0 * FRAC_PI_2),
|
||||
),
|
||||
// A bent path (a line, then a quarter arc) needs the curve sweep: π r² times the path's
|
||||
// length, 10 + 10 π/2, within the walls' fit.
|
||||
"sweep_along_a_bent_path" => c(
|
||||
Op::Sweep(
|
||||
vec![circle([0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0)],
|
||||
vec![
|
||||
line([0.0; 3], [10.0, 0.0, 0.0]),
|
||||
Curve::Arc { center: [10.0, 10.0, 0.0], axis: Z, reference: [0.0, -1.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 },
|
||||
],
|
||||
SweepFrame::Follow,
|
||||
true,
|
||||
),
|
||||
Expect::About(PI * (10.0 + 10.0 * FRAC_PI_2), 1e-4),
|
||||
),
|
||||
// A 4 x 3 rectangle round a closed 60 x 40 rounded rectangle (corner radius 8), from the
|
||||
// middle of its bottom line: no caps, the walls closing on each other. Pappus: A L, with
|
||||
// A = 12 and L = 2 (44 + 24) + 4 (π/2) 8.
|
||||
"sweep_round_a_closed_path" => {
|
||||
let arc = |c: [f64; 3], r: [f64; 3]| Curve::Arc { center: c, axis: Z, reference: r, radius: 8.0, start: 0.0, end: FRAC_PI_2 };
|
||||
let path = vec![
|
||||
line([0.0, -20.0, 0.0], [22.0, -20.0, 0.0]),
|
||||
arc([22.0, -12.0, 0.0], [0.0, -1.0, 0.0]),
|
||||
line([30.0, -12.0, 0.0], [30.0, 12.0, 0.0]),
|
||||
arc([22.0, 12.0, 0.0], [1.0, 0.0, 0.0]),
|
||||
line([22.0, 20.0, 0.0], [-22.0, 20.0, 0.0]),
|
||||
arc([-22.0, 12.0, 0.0], [0.0, 1.0, 0.0]),
|
||||
line([-30.0, 12.0, 0.0], [-30.0, -12.0, 0.0]),
|
||||
arc([-22.0, -12.0, 0.0], [-1.0, 0.0, 0.0]),
|
||||
line([-22.0, -20.0, 0.0], [0.0, -20.0, 0.0]),
|
||||
];
|
||||
c(
|
||||
Op::Sweep(polygon(&[[0.0, -22.0, -1.5], [0.0, -18.0, -1.5], [0.0, -18.0, 1.5], [0.0, -22.0, 1.5]]), path, SweepFrame::Follow, true),
|
||||
Expect::About(12.0 * (2.0 * (44.0 + 24.0) + 16.0 * PI), 1e-4),
|
||||
)
|
||||
}
|
||||
// The frustum of a square pyramid: side 20 to 10 over 10.
|
||||
"loft_two_squares" => {
|
||||
c(Op::Loft(vec![square(0.0, 0.0, 20.0, 0.0), square(5.0, 5.0, 10.0, 10.0)], 1, true), Expect::Volume(frustum(400.0, 100.0, 10.0)))
|
||||
}
|
||||
// The cone frustum: radius 5 to 3 over 10.
|
||||
"loft_two_circles" => c(
|
||||
Op::Loft(vec![disc(), vec![circle([0.0, 0.0, 10.0], Z, [1.0, 0.0, 0.0], 3.0)]], 1, true),
|
||||
Expect::Volume(frustum(25.0 * PI, 9.0 * PI, 10.0)),
|
||||
),
|
||||
// A h / 3.
|
||||
"loft_to_point_pyramid" => c(Op::LoftToPoint(square(0.0, 0.0, 10.0, 0.0), [5.0, 5.0, 6.0]), Expect::Volume(200.0)),
|
||||
// A square of 2 carried up 10: 40.
|
||||
"sweep_stations_prism" => c(
|
||||
Op::Stations(
|
||||
square(-1.0, -1.0, 2.0, 0.0),
|
||||
vec![translate([0.0; 3]), translate([0.0, 0.0, 5.0]), translate([0.0, 0.0, 10.0])],
|
||||
Ends::Capped,
|
||||
),
|
||||
Expect::Volume(40.0),
|
||||
),
|
||||
// Scaled by s = 1 + z/10 on the way: ∫ (2 s)² dz = 4 · 10 · (2³ - 1) / 3.
|
||||
"sweep_stations_scaled" => c(
|
||||
Op::Stations(square(-1.0, -1.0, 2.0, 0.0), vec![scale_up(1.0, 0.0), scale_up(1.5, 5.0), scale_up(2.0, 10.0)], Ends::Capped),
|
||||
Expect::Volume(4.0 * 10.0 * 7.0 / 3.0),
|
||||
),
|
||||
// A square thread 1 x 1 from radius 5 to 6, pitch 2, two turns: Pappus along the screw
|
||||
// motion (its rise lies in the profile's plane), A · 2π x̄ · turns, the walls within the
|
||||
// tolerance of the screw surfaces.
|
||||
"screw_sweep_square_thread" => c(
|
||||
Op::Screw(polygon(&[[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 0.0, 1.0], [5.0, 0.0, 1.0]]), [0.0; 3], Z, 2.0, 2.0, 1e-5),
|
||||
Expect::About(TAU * 5.5 * 2.0, 1e-5),
|
||||
),
|
||||
// The same thread moved out by 0 to 1 over its two turns, linearly: Pappus with the
|
||||
// centroid at 5.5 + t/2, A · 2π · turns · (5.5 + 1/2).
|
||||
"screw_sweep_radial_taper" => c(
|
||||
Op::ScrewRadial(
|
||||
polygon(&[[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 0.0, 1.0], [5.0, 0.0, 1.0]]),
|
||||
[0.0; 3],
|
||||
Z,
|
||||
2.0,
|
||||
2.0,
|
||||
vec![(0.0, 0.0), (2.0, 1.0)],
|
||||
1e-5,
|
||||
),
|
||||
Expect::About(TAU * 2.0 * 6.0, 1e-5),
|
||||
),
|
||||
_ => panic!("{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Measured {
|
||||
/// The volume of a solid, the area of a sheet.
|
||||
size: f64,
|
||||
counts: [usize; 3],
|
||||
kinds: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for f in &rec.faces {
|
||||
let v = serde_json::to_value(&f.surface).unwrap();
|
||||
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn on_core(op: &Op) -> crate::Result<Body> {
|
||||
match op {
|
||||
Op::PlanarFaces(l) => planar_faces(l),
|
||||
Op::Extrude(p, d, dist, draft, corner, cap) => extrude(p, *d, *dist, *draft, *corner, *cap),
|
||||
Op::Revolve(p, o, a, angle, cap) => revolve(p, *o, *a, *angle, *cap),
|
||||
Op::Sweep(p, path, frame, cap) => sweep_placed(p, path, *frame, *cap).map(|(b, _)| b),
|
||||
Op::Loft(s, d, cap) => loft(s, *d, *cap),
|
||||
Op::LoftToPoint(s, apex) => loft_to_point(s, *apex),
|
||||
Op::Stations(p, s, e) => sweep_stations(p, s, *e),
|
||||
Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol),
|
||||
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
|
||||
fn timed<T: Send + 'static>(name: &str, f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let _ = tx.send(f());
|
||||
});
|
||||
match rx.recv_timeout(TIMEOUT) {
|
||||
Ok(v) => v,
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => match worker.join() {
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
Ok(()) => panic!("{name}: no result"),
|
||||
},
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => panic!("{name}: solidcore hung (no result after {TIMEOUT:?})"),
|
||||
}
|
||||
}
|
||||
|
||||
type Got = std::result::Result<Measured, Error>;
|
||||
|
||||
/// Checks a kernel's result against the case's closed form; what it measured, or its error.
|
||||
fn judge(name: &str, who: &str, expect: &Expect, got: Got) -> Got {
|
||||
match (expect, &got) {
|
||||
(Expect::Corner(why), Err(g)) => {
|
||||
assert!(
|
||||
matches!(g, Error::Corner { why: w, at } if w == why && at.iter().all(|x| x.is_finite())),
|
||||
"{name}: {who} failed otherwise: {g:?}"
|
||||
)
|
||||
}
|
||||
(Expect::Corner(_), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {expect:?}"),
|
||||
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(Expect::Volume(v) | Expect::Sheet(v), Ok(m)) => assert!(rel(m.size, *v) < 1e-9, "{name}: {who} {} vs analytic {v}", m.size),
|
||||
(Expect::About(v, within), Ok(m)) => assert!(rel(m.size, *v) < *within, "{name}: {who} {} vs analytic {v}", m.size),
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn solidcore_case(name: &'static str) -> Got {
|
||||
timed(name, move || {
|
||||
let c = case(name);
|
||||
let sheet = matches!(c.expect, Expect::Sheet(_));
|
||||
let got = on_core(&c.op).map(|body| {
|
||||
let size = if sheet { area(&body, 1e-8) } else { volume(&body, 1e-8) }.unwrap_or_else(|e| panic!("{name}: size: {e}"));
|
||||
let n = counts(&body).unwrap();
|
||||
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
bounds(&body).unwrap();
|
||||
Measured { size, counts: n, kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "solidcore", &c.expect, got)
|
||||
})
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, through the public sweeps forced onto it on this thread.
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Got {
|
||||
use crate::{Brep, ffi};
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let c = case(name);
|
||||
let r = match &c.op {
|
||||
Op::PlanarFaces(l) => Brep::planar_faces(l),
|
||||
Op::Extrude(p, d, dist, draft, corner, cap) => Brep::extrude(p, *d, *dist, *draft, *corner, *cap),
|
||||
Op::Revolve(p, o, a, angle, cap) => Brep::revolve(p, *o, *a, *angle, *cap),
|
||||
Op::Sweep(p, path, frame, cap) => Brep::sweep(p, path, *frame, *cap),
|
||||
Op::Loft(s, d, cap) => Brep::loft(s, *d, *cap),
|
||||
Op::LoftToPoint(s, apex) => Brep::loft_to_point(s, *apex),
|
||||
Op::Stations(p, s, e) => Brep::sweep_stations(p, s, *e),
|
||||
Op::Screw(p, o, a, pitch, turns, tol) => Brep::screw_sweep(p, *o, *a, *pitch, *turns, *tol),
|
||||
Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => Brep::screw_sweep_radial(p, *o, *a, *pitch, *turns, law, *tol),
|
||||
};
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
let sheet = matches!(c.expect, Expect::Sheet(_));
|
||||
let got = r.map(|b| {
|
||||
let h = b.cpp().unwrap();
|
||||
let size = if sheet { ffi::measure::area(h, 1e-8) } else { ffi::volume(h, 1e-8) }.unwrap();
|
||||
let n = ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Measured { size, counts: n, kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "C++", &c.expect, got)
|
||||
}
|
||||
|
||||
/// solidcore and C++ agree: volume (area) within 1e-9, the same counts and faces by kind, or the
|
||||
/// same error.
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &'static str) {
|
||||
let cpp = cpp_case(name);
|
||||
let ours = solidcore_case(name);
|
||||
match (ours, cpp) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
assert!(rel(a.size, b.size) < 1e-9, "{name}: solidcore {} vs C++ {}", a.size, b.size);
|
||||
assert_eq!(a.counts, b.counts, "{name}: face, edge, vertex counts (solidcore vs C++)");
|
||||
assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)");
|
||||
}
|
||||
(a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $([$pign:meta])? $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
$(#[$pign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::cpp_case(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
// Cases marked `[ignore = …]` match the closed form on solidcore but not the C++ kernel's
|
||||
// numbers; `: ignore = …` ones fail on solidcore.
|
||||
cases!(
|
||||
planar_faces_with_a_hole,
|
||||
extrude_rectangle,
|
||||
extrude_circle,
|
||||
extrude_circle_in_two_halves,
|
||||
extrude_circle_in_two_frames,
|
||||
extrude_half_disc_split_arc,
|
||||
extrude_square_drafted_round,
|
||||
extrude_square_drafted_curvature,
|
||||
extrude_circle_drafted,
|
||||
extrude_draft_wall_closes,
|
||||
revolve_rectangle_full,
|
||||
revolve_rectangle_quarter,
|
||||
sweep_circle_straight,
|
||||
sweep_circle_along_an_arc,
|
||||
sweep_along_a_bent_path,
|
||||
sweep_round_a_closed_path,
|
||||
loft_two_squares,
|
||||
loft_two_circles,
|
||||
loft_to_point_pyramid,
|
||||
sweep_stations_prism,
|
||||
sweep_stations_scaled,
|
||||
screw_sweep_square_thread,
|
||||
screw_sweep_radial_taper,
|
||||
);
|
||||
|
||||
/// Arcs of a profile on one circle sweep one wall face (tangency-labs/smlib 7dee48f): a circle
|
||||
/// in two pieces extrudes to the faces, edges and vertices of the whole circle (two caps and one
|
||||
/// cylinder: 3 faces, 3 edges with the seam, 2 vertices) and the area 2πr² + 2πrh; a half disc
|
||||
/// whose arc is in two quarters to its diameter's plane wall and one cylinder (4 faces, 6 edges,
|
||||
/// 4 vertices) and the area πr² + πrh + 2rh. On solidcore, and with SMLib built on the C++ kernel.
|
||||
#[test]
|
||||
fn arcs_on_one_circle_sweep_one_wall() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let cases = [
|
||||
("extrude_circle_in_two_halves", [3, 3, 2], 50.0 * PI + 100.0 * PI, true),
|
||||
("extrude_circle_in_two_frames", [3, 3, 2], 50.0 * PI + 100.0 * PI, true),
|
||||
// solidcore sweeps a partial arc, merged or drawn whole, to a surface of extrusion (a
|
||||
// rational spline in the record) where the C++ kernel makes a cylinder: one wall still.
|
||||
("extrude_half_disc_split_arc", [4, 6, 4], 25.0 * PI + 50.0 * PI + 100.0, false),
|
||||
];
|
||||
let walls = |kinds: &BTreeMap<String, usize>| kinds.iter().filter(|(k, _)| !k.starts_with("plane")).map(|(_, n)| *n).sum::<usize>();
|
||||
for (name, want_counts, want_area, cylinder_on_core) in cases {
|
||||
let (counts_got, kinds_got, area_got) = timed(name, move || {
|
||||
let body = on_core(&case(name).op).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap();
|
||||
(counts(&body).unwrap(), kinds(&rec), area(&body, 1e-8).unwrap())
|
||||
});
|
||||
assert_eq!(counts_got, want_counts, "{name}: solidcore faces, edges, vertices");
|
||||
assert_eq!(walls(&kinds_got), 1, "{name}: solidcore curved walls {kinds_got:?}");
|
||||
if cylinder_on_core {
|
||||
assert_eq!(kinds_got.get("cylinder"), Some(&1), "{name}: solidcore walls {kinds_got:?}");
|
||||
}
|
||||
assert!(rel(area_got, want_area) < 1e-9, "{name}: solidcore area {area_got} vs analytic {want_area}");
|
||||
#[cfg(feature = "native")]
|
||||
{
|
||||
use crate::{Brep, ffi};
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let r = match case(name).op {
|
||||
Op::Extrude(p, d, dist, draft, corner, cap) => Brep::extrude(&p, d, dist, draft, corner, cap),
|
||||
op => panic!("{name}: {op:?}"),
|
||||
};
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
let b = r.unwrap_or_else(|e| panic!("{name}: C++: {e}"));
|
||||
let h = b.cpp().unwrap();
|
||||
let n = ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap();
|
||||
assert_eq!(n, want_counts, "{name}: C++ faces, edges, vertices");
|
||||
assert_eq!(walls(&kinds(&rec)), 1, "{name}: C++ curved walls {:?}", kinds(&rec));
|
||||
assert_eq!(kinds(&rec).get("cylinder"), Some(&1), "{name}: C++ walls {:?}", kinds(&rec));
|
||||
let a = ffi::measure::area(h, 1e-8).unwrap();
|
||||
assert!(rel(a, want_area) < 1e-9, "{name}: C++ area {a} vs analytic {want_area}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn near(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9)
|
||||
}
|
||||
|
||||
fn same_motion(a: &Affine, b: &Affine) -> bool {
|
||||
a.m.iter().zip(&b.m).all(|(x, y)| near(*x, *y)) && near(a.t, b.t)
|
||||
}
|
||||
|
||||
/// The motions of a quarter turn about the z axis: by 0, π/4 and π/2.
|
||||
fn quarter_turn() -> [Affine; 3] {
|
||||
[0.0, FRAC_PI_2 / 2.0, FRAC_PI_2].map(|a: f64| {
|
||||
let (c, s) = (a.cos(), a.sin());
|
||||
Affine { m: [[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3] }
|
||||
})
|
||||
}
|
||||
|
||||
fn straight_up() -> (Vec<Curve>, Vec<Curve>) {
|
||||
(vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 2.0)], vec![line([0.0; 3], [0.0, 0.0, 10.0])])
|
||||
}
|
||||
|
||||
fn round_z() -> (Vec<Curve>, Vec<Curve>) {
|
||||
(
|
||||
vec![circle([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0)],
|
||||
vec![Curve::Arc { center: [0.0; 3], axis: Z, reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }],
|
||||
)
|
||||
}
|
||||
|
||||
fn check_motions(placed: &[Affine; 3], want: &[Affine; 3]) {
|
||||
assert!(placed.iter().zip(want).all(|(a, b)| same_motion(a, b)), "{placed:?}, expected {want:?}");
|
||||
}
|
||||
|
||||
/// `sweep_placed`: along a straight path the profile only moves, by none, half and all of the
|
||||
/// path; along an arc about z it turns with it about the arc's centre, by none, half and all of
|
||||
/// the arc's angle.
|
||||
#[test]
|
||||
fn sweep_placed_reports_the_motions() {
|
||||
let (placed, turned) = timed("placed", || {
|
||||
let (p, up) = straight_up();
|
||||
let (q, arc) = round_z();
|
||||
(sweep_placed(&p, &up, SweepFrame::Follow, true).unwrap().1, sweep_placed(&q, &arc, SweepFrame::Follow, true).unwrap().1)
|
||||
});
|
||||
check_motions(&placed, &[0.0, 5.0, 10.0].map(|z| translate([0.0, 0.0, z])));
|
||||
check_motions(&turned, &quarter_turn());
|
||||
}
|
||||
|
||||
/// The same motions on the C++ kernel.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_places_the_profile_alike() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let (p, up) = straight_up();
|
||||
let (q, arc) = round_z();
|
||||
let placed = crate::Brep::sweep_placed(&p, &up, SweepFrame::Follow, true).unwrap().1;
|
||||
let turned = crate::Brep::sweep_placed(&q, &arc, SweepFrame::Follow, true).unwrap().1;
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
check_motions(&placed, &[0.0, 5.0, 10.0].map(|z| translate([0.0, 0.0, z])));
|
||||
check_motions(&turned, &quarter_turn());
|
||||
}
|
||||
|
||||
/// A diagonal 10 above a 10 x 10 x 10 box's top, projected down onto the top: the same diagonal
|
||||
/// at z = 10.
|
||||
#[test]
|
||||
fn project_a_line_onto_a_face() {
|
||||
let got = timed("project", || {
|
||||
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
project_curves(&b, [5.0, 5.0, 10.0], 0.01, &[line([1.0, 1.0, 20.0], [9.0, 9.0, 20.0])], [0.0, 0.0, -1.0], 1e-6)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
let Some((Curve::Nurbs { points, .. }, _)) = got.first() else { panic!("{got:?}") };
|
||||
let (a, b) = (points[0], points[points.len() - 1]);
|
||||
let ends = [[a[0], a[1], a[2]], [b[0], b[1], b[2]]];
|
||||
let want = [[1.0, 1.0, 10.0], [9.0, 9.0, 10.0]];
|
||||
assert!((near(ends[0], want[0]) && near(ends[1], want[1])) || (near(ends[0], want[1]) && near(ends[1], want[0])), "{ends:?}");
|
||||
}
|
||||
|
||||
/// A point of a section curve: the rational B-spline at `u` (de Boor on the weighted points).
|
||||
fn at(c: &Curve, u: f64) -> [f64; 3] {
|
||||
let Curve::Nurbs { degree, points, knots } = c else { panic!("{c:?}") };
|
||||
let p = *degree;
|
||||
let span = (p..points.len()).rev().find(|&i| knots[i] <= u).unwrap_or(p);
|
||||
let mut d: Vec<[f64; 4]> = (0..=p).map(|j| points[span - p + j]).map(|[x, y, z, w]| [x * w, y * w, z * w, w]).collect();
|
||||
for r in 1..=p {
|
||||
for j in (r..=p).rev() {
|
||||
let i = span - p + j;
|
||||
let den = knots[i + p + 1 - r] - knots[i];
|
||||
let a = if den > 0.0 { (u - knots[i]) / den } else { 0.0 };
|
||||
let prev = d[j - 1];
|
||||
for (x, q) in d[j].iter_mut().zip(prev) {
|
||||
*x = (1.0 - a) * q + a * *x;
|
||||
}
|
||||
}
|
||||
}
|
||||
let [x, y, z, w] = d[p];
|
||||
[x / w, y / w, z / w]
|
||||
}
|
||||
|
||||
/// `n + 1` points along each section curve over its interval.
|
||||
fn samples(got: &[(Curve, [f64; 2])], n: usize) -> Vec<Vec<[f64; 3]>> {
|
||||
got.iter().map(|(c, [a, b])| (0..=n).map(|i| at(c, a + (b - a) * i as f64 / n as f64)).collect()).collect()
|
||||
}
|
||||
|
||||
/// The length of the section curves, from chords.
|
||||
fn length(pts: &[Vec<[f64; 3]>]) -> f64 {
|
||||
pts.iter().flat_map(|p| p.windows(2)).map(|w| (0..3).map(|i| (w[1][i] - w[0][i]).powi(2)).sum::<f64>().sqrt()).sum()
|
||||
}
|
||||
|
||||
/// A loft from a 10 x 10 square at z = 0 to a 6 x 6 one at z = 10 (degree 1): its front wall
|
||||
/// runs from y = 0 up to y = 2, so the plane z = 5 cuts it in the line from (9, 1, 5) to
|
||||
/// (1, 1, 5).
|
||||
fn square_frustum() -> Vec<Vec<Curve>> {
|
||||
vec![square(0.0, 0.0, 10.0, 0.0), square(2.0, 2.0, 6.0, 10.0)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lofted_wall_section_is_the_line_across_it() {
|
||||
let got = timed("section", || {
|
||||
let b = loft(&square_frustum(), 1, true).unwrap();
|
||||
face_section(&b, [5.0, 1.0, 5.0], 0.01, [0.0, 0.0, 5.0], [0.0, 0.0, 1.0], 1e-6)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
let p = samples(&got, 8);
|
||||
let ends = [p[0][0], p[0][8]];
|
||||
let want = [[9.0, 1.0, 5.0], [1.0, 1.0, 5.0]];
|
||||
assert!((near(ends[0], want[0]) && near(ends[1], want[1])) || (near(ends[0], want[1]) && near(ends[1], want[0])), "{ends:?}");
|
||||
assert!(p[0].iter().all(|q| (q[1] - 1.0).abs() < 1e-9 && (q[2] - 5.0).abs() < 1e-9), "{p:?}");
|
||||
}
|
||||
|
||||
/// The plane z = 3 through the side of a cylinder of radius 5 and height 10 on the xy plane: the
|
||||
/// circle of radius 5 at z = 3, 2π·5 long.
|
||||
#[test]
|
||||
fn a_cylinder_side_section_is_its_circle() {
|
||||
let got = timed("section", || {
|
||||
let b = make_cylinder([0.0; 3], 5.0, 10.0).unwrap();
|
||||
face_section(&b, [5.0, 0.0, 5.0], 0.01, [0.0, 0.0, 3.0], [0.0, 0.0, 1.0], 1e-7)
|
||||
})
|
||||
.unwrap();
|
||||
let p = samples(&got, 2000);
|
||||
for q in p.iter().flatten() {
|
||||
assert!(((q[0] * q[0] + q[1] * q[1]).sqrt() - 5.0).abs() < 1e-6 && (q[2] - 3.0).abs() < 1e-9, "{q:?}");
|
||||
}
|
||||
let len = length(&p);
|
||||
assert!((len - TAU * 5.0).abs() < 1e-4, "{len} against {}", TAU * 5.0);
|
||||
}
|
||||
|
||||
/// The plane x = 5 through the top of a 10 mm box: the line (5, 0, 10)-(5, 10, 10) across it.
|
||||
#[test]
|
||||
fn a_box_top_section_is_the_line_across_it() {
|
||||
let got = timed("section", || {
|
||||
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
face_section(&b, [5.0, 5.0, 10.0], 0.01, [5.0, 5.0, 5.0], [1.0, 0.0, 0.0], 1e-7)
|
||||
})
|
||||
.unwrap();
|
||||
let p = samples(&got, 8);
|
||||
assert!(p.iter().flatten().all(|q| (q[0] - 5.0).abs() < 1e-9 && (q[2] - 10.0).abs() < 1e-9), "{p:?}");
|
||||
let len = length(&p);
|
||||
assert!((len - 10.0).abs() < 1e-6, "{len} against 10");
|
||||
}
|
||||
|
||||
/// The same sections on the C++ kernel: the lofted wall's line along the same points. On a box's
|
||||
/// top and a cylinder's side, whose analytic intersectors make no UV curves, the capi leaves
|
||||
/// `bForceUVClassify` FALSE and answers [`Error::Empty`] (solidcore#1769), where the port has the
|
||||
/// section.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_sections_a_face_alike() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
use crate::{Brep, ffi};
|
||||
type Case = (&'static str, Box<dyn Fn() -> Body>, Brep, [[f64; 3]; 3]);
|
||||
let cases: [Case; 3] = [
|
||||
(
|
||||
"loft",
|
||||
Box::new(|| loft(&square_frustum(), 1, true).unwrap()),
|
||||
{
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let b = Brep::loft(&square_frustum(), 1, true).unwrap();
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
b
|
||||
},
|
||||
[[5.0, 1.0, 5.0], [0.0, 0.0, 5.0], [0.0, 0.0, 1.0]],
|
||||
),
|
||||
(
|
||||
"box",
|
||||
Box::new(|| make_box([0.0; 3], [10.0; 3]).unwrap()),
|
||||
ffi::make_box(&[0.0; 3], &[10.0; 3]).map(Brep::made).unwrap(),
|
||||
[[5.0, 5.0, 10.0], [5.0, 5.0, 5.0], [1.0, 0.0, 0.0]],
|
||||
),
|
||||
(
|
||||
"cylinder",
|
||||
Box::new(|| make_cylinder([0.0; 3], 5.0, 10.0).unwrap()),
|
||||
ffi::make_cylinder(&[0.0; 3], 5.0, 10.0).map(Brep::made).unwrap(),
|
||||
[[5.0, 0.0, 5.0], [0.0, 0.0, 3.0], [0.0, 0.0, 1.0]],
|
||||
),
|
||||
];
|
||||
for (name, core, cpp, [face, through, n]) in cases {
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(true));
|
||||
let want = cpp.face_section(face, 0.01, through, n, 1e-6);
|
||||
crate::sweeps::ON_CPP.with(|c| c.set(false));
|
||||
let got = face_section(&core(), face, 0.01, through, n, 1e-6);
|
||||
let (got, want) = match (got, want) {
|
||||
(Ok(g), Ok(w)) => (g, w),
|
||||
(Ok(g), Err(Error::Empty)) if name != "loft" => {
|
||||
assert!(!g.is_empty(), "{name}: the port has the section the capi drops");
|
||||
continue;
|
||||
}
|
||||
(g, w) => panic!("{name}: {:?} against {:?}", g.err(), w.err()),
|
||||
};
|
||||
assert_eq!(got.len(), want.len(), "{name}: {got:?} against {want:?}");
|
||||
let (g, w) = (samples(&got, 64), samples(&want, 64));
|
||||
for (a, b) in g.iter().flatten().zip(w.iter().flatten()) {
|
||||
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9), "{name}: {a:?} against {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The capi's argument checks, before any kernel call.
|
||||
#[test]
|
||||
fn hostile_arguments_are_refused() {
|
||||
let sq = square(0.0, 0.0, 1.0, 0.0);
|
||||
let d = DraftCorner::default();
|
||||
for dist in [0.0, -1.0, f64::NAN, 1e10] {
|
||||
assert_eq!(extrude(&sq, Z, dist, 0.0, d, true).err(), Some(Error::Argument), "distance {dist}");
|
||||
}
|
||||
assert_eq!(extrude(&sq, Z, 1.0, FRAC_PI_2, d, true).err(), Some(Error::Argument), "a right-angled draft");
|
||||
assert_eq!(extrude(&sq, [0.0; 3], 1.0, 0.0, d, true).err(), Some(Error::Argument), "no direction");
|
||||
assert_eq!(extrude(&[], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument), "no profile");
|
||||
assert_eq!(extrude(&[line([0.0; 3], [0.0; 3])], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument), "a point");
|
||||
assert_eq!(extrude(&[line([f64::NAN; 3], [1.0; 3])], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument));
|
||||
// A drafted profile is one loop, square to the direction.
|
||||
assert_eq!(extrude(&sq[..3], Z, 1.0, 0.1, d, true).err(), Some(Error::Argument), "an open profile");
|
||||
assert_eq!(extrude(&sq, [1.0, 0.0, 0.0], 1.0, 0.1, d, true).err(), Some(Error::Argument), "along the profile");
|
||||
let bad_arc = Curve::Arc { center: [0.0; 3], axis: Z, reference: Z, radius: 1.0, start: 0.0, end: 1.0 };
|
||||
assert_eq!(extrude(&[bad_arc], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "reference along the axis");
|
||||
let bad_nurbs = Curve::Nurbs { degree: 1, points: vec![[0.0; 4], [1.0, 0.0, 0.0, 1.0]], knots: vec![0.0, 0.0, 1.0, 1.0] };
|
||||
assert_eq!(extrude(&[bad_nurbs], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "a zero weight");
|
||||
let unclamped = Curve::Nurbs { degree: 1, points: vec![[0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]], knots: vec![0.0, 0.5, 1.0, 1.0] };
|
||||
assert_eq!(extrude(&[unclamped], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "unclamped knots");
|
||||
assert_eq!(revolve(&sq, [0.0; 3], Z, 0.0, true).err(), Some(Error::Argument), "no angle");
|
||||
assert_eq!(revolve(&sq, [0.0; 3], Z, 7.0, true).err(), Some(Error::Argument), "more than a turn");
|
||||
assert_eq!(revolve(&sq, [f64::INFINITY; 3], Z, 1.0, true).err(), Some(Error::Argument));
|
||||
assert_eq!(loft(std::slice::from_ref(&sq), 1, true).err(), Some(Error::Argument), "one section");
|
||||
assert_eq!(loft(&[sq.clone(), sq.clone()], 4, true).err(), Some(Error::Argument), "degree 4");
|
||||
assert_eq!(loft_to_point(&sq, [0.5, 0.5, 0.0]).err(), Some(Error::Argument), "apex in the base's plane");
|
||||
assert_eq!(loft_to_point(&[circle([0.0; 3], Z, [1.0, 0.0, 0.0], 1.0)], Z).err(), Some(Error::Argument), "not lines");
|
||||
let flat = Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], t: [0.0; 3] };
|
||||
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3]), flat], Ends::Capped).err(), Some(Error::Argument), "a singular map");
|
||||
assert_eq!(sweep_stations(&sq, &[translate([0.0; 3])], Ends::Capped).err(), Some(Error::Argument), "one station");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3).err(), Some(Error::Argument), "no pitch");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3).err(), Some(Error::Argument), "too many turns");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3).err(), Some(Error::Argument), "a law going back");
|
||||
assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3).err(), Some(Error::Argument), "an empty law");
|
||||
let b = make_box([0.0; 3], [1.0; 3]).unwrap();
|
||||
assert_eq!(project_curves(&b, [0.5; 3], 0.01, &sq, [0.0; 3], 1e-6).err(), Some(Error::Argument), "no direction");
|
||||
assert_eq!(project_curves(&b, [0.5; 3], 0.0, &sq, Z, 1e-6).err(), Some(Error::Argument), "no pick tolerance");
|
||||
assert_eq!(face_section(&b, [0.5; 3], 0.01, [0.5; 3], [0.0; 3], 1e-6).err(), Some(Error::Argument), "no plane normal");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Copies and affine transforms on solidcore: the C API's `smlib_brep_copy` and
|
||||
//! `smlib_brep_transform`, ported from the fork's `capi/src/smlib_c.cpp`.
|
||||
//!
|
||||
//! As there, a transform works on a copy of its body (`SmApiBrepCopy`), which
|
||||
//! `SmBrep::Transform(double[4][4], bScaleTol)` (solidcore's `transform_matrix`, solidcore#1570)
|
||||
//! then moves in place, tolerances scaling with the model. The matrix is the capi's: rows 0-2 the
|
||||
//! images of x, y and z, row 3 the translation (row-vector convention, `p' = p·A + t`); the
|
||||
//! fourth column is not read.
|
||||
|
||||
use super::Body;
|
||||
use super::blend::{on_copy, status};
|
||||
use crate::{Error, Result};
|
||||
use solidcore::topology::brep::mirror::transform_matrix;
|
||||
|
||||
/// `smlib_brep_copy`: `SmApiBrepCopy`.
|
||||
pub(crate) fn copy(body: &Body) -> Result<Body> {
|
||||
on_copy("SmApiBrepCopy", body, |_, b| Ok(Ok(b)))
|
||||
}
|
||||
|
||||
/// `smlib_brep_transform`: `SmBrep::Transform(matrix, TRUE)` on a copy of `body`. Every entry of
|
||||
/// the matrix must be finite (`SMLIB_ERR_ARG`).
|
||||
pub(crate) fn transform(body: &Body, matrix: &[[f64; 4]; 4]) -> Result<Body> {
|
||||
if !matrix.iter().flatten().all(|x| x.is_finite()) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
on_copy("SmBrep::Transform", body, |k, mut b| {
|
||||
Ok(match transform_matrix(k, &mut b, matrix, true) {
|
||||
Ok(()) => Ok(b),
|
||||
Err(e) => Err(status(e)),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Copies and affine transforms on solidcore, against closed forms: every result keeps its counts,
|
||||
//! its volume scales by the matrix's |det|, its bounds are the moved box's, and it exports to a
|
||||
//! record that validates. With SMLib built, the same case on the C++ kernel gives the same
|
||||
//! volume, counts, bounds and faces by surface kind, or the same error (parity only: the
|
||||
//! expected values are the analytic ones).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use super::transform::{copy, transform};
|
||||
use super::{Body, bounds, counts, make_box, make_cylinder, to_arrays, volume};
|
||||
use crate::Error;
|
||||
use crate::export::native;
|
||||
|
||||
/// The identity, as the capi reads a matrix: rows 0-2 the images of x, y and z, row 3 the move.
|
||||
const ID: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Shape {
|
||||
/// A box from its lower corner.
|
||||
Box([f64; 3], [f64; 3]),
|
||||
/// A cylinder about +Z from its base centre.
|
||||
Cylinder([f64; 3], f64, f64),
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn core(self) -> Body {
|
||||
match self {
|
||||
Shape::Box(o, s) => make_box(o, s).unwrap(),
|
||||
Shape::Cylinder(o, r, h) => make_cylinder(o, r, h).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume(self) -> f64 {
|
||||
match self {
|
||||
Shape::Box(_, s) => s[0] * s[1] * s[2],
|
||||
Shape::Cylinder(_, r, h) => PI * r * r * h,
|
||||
}
|
||||
}
|
||||
|
||||
fn counts(self) -> [usize; 3] {
|
||||
match self {
|
||||
Shape::Box(..) => [6, 12, 8],
|
||||
Shape::Cylinder(..) => [3, 3, 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// The corners of its bounding box.
|
||||
fn corners(self) -> Vec<[f64; 3]> {
|
||||
let (lo, hi) = match self {
|
||||
Shape::Box(o, s) => (o, [o[0] + s[0], o[1] + s[1], o[2] + s[2]]),
|
||||
Shape::Cylinder(o, r, h) => ([o[0] - r, o[1] - r, o[2]], [o[0] + r, o[1] + r, o[2] + h]),
|
||||
};
|
||||
(0..8)
|
||||
.map(|k| [if k & 1 == 0 { lo[0] } else { hi[0] }, if k & 2 == 0 { lo[1] } else { hi[1] }, if k & 4 == 0 { lo[2] } else { hi[2] }])
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `p·A + t`.
|
||||
fn apply(m: &[[f64; 4]; 4], p: [f64; 3]) -> [f64; 3] {
|
||||
[0, 1, 2].map(|j| p[0] * m[0][j] + p[1] * m[1][j] + p[2] * m[2][j] + m[3][j])
|
||||
}
|
||||
|
||||
fn det(m: &[[f64; 4]; 4]) -> f64 {
|
||||
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
fn rotation_z(a: f64) -> [[f64; 4]; 4] {
|
||||
let (s, c) = a.sin_cos();
|
||||
[[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]
|
||||
}
|
||||
|
||||
struct Case {
|
||||
shape: Shape,
|
||||
matrix: [[f64; 4]; 4],
|
||||
/// `None`: the closed form (volume times |det|, the moved box, the same counts).
|
||||
fails: Option<Error>,
|
||||
}
|
||||
|
||||
const BOX: Shape = Shape::Box([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]);
|
||||
const CYL: Shape = Shape::Cylinder([0.0, 0.0, 0.0], 5.0, 12.0);
|
||||
|
||||
fn case(name: &str) -> Case {
|
||||
let ok = |shape, matrix| Case { shape, matrix, fails: None };
|
||||
let mut moved = ID;
|
||||
moved[3] = [5.0, -7.0, 11.0, 1.0];
|
||||
match name {
|
||||
"identity" => ok(BOX, ID),
|
||||
"translation" => ok(BOX, moved),
|
||||
"rotation_about_z" => ok(BOX, rotation_z(PI / 2.0)),
|
||||
"uniform_scale" => ok(BOX, [[2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0], [1.0, 1.0, 1.0, 1.0]]),
|
||||
"mirror_in_x" => ok(BOX, [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0; 4]]),
|
||||
"non_uniform_scale" => ok(BOX, [[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [0.0; 4]]),
|
||||
"shear" => ok(BOX, [[1.0, 0.5, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [2.0, 3.0, 4.0, 1.0]]),
|
||||
"cylinder_moved" => ok(CYL, moved),
|
||||
"cylinder_rotated" => ok(CYL, rotation_z(0.3)),
|
||||
"cylinder_mirrored" => ok(CYL, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0; 4]]),
|
||||
"cylinder_scaled" => ok(CYL, [[3.0, 0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0], [0.0; 4]]),
|
||||
_ => panic!("no case {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Measured {
|
||||
volume: f64,
|
||||
bounds: ([f64; 3], [f64; 3]),
|
||||
counts: [usize; 3],
|
||||
kinds: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
type Got = std::result::Result<Measured, Error>;
|
||||
|
||||
fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap<String, usize> {
|
||||
let mut out = BTreeMap::new();
|
||||
for f in &rec.faces {
|
||||
let v = serde_json::to_value(&f.surface).unwrap();
|
||||
*out.entry(v["kind"].as_str().unwrap_or("?").to_owned()).or_default() += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
/// The moved shape's bounding box: a box's moved corners bound it exactly; a cylinder's only for
|
||||
/// the matrices here (a rotation about its axis, a move, a mirror in z, a uniform scale), whose
|
||||
/// moved circle's box is the moved square's turned back to the axes.
|
||||
fn expected_bounds(c: &Case) -> ([f64; 3], [f64; 3]) {
|
||||
let pts: Vec<[f64; 3]> = match c.shape {
|
||||
Shape::Cylinder(o, r, h) => {
|
||||
let s = (c.matrix[0][0] * c.matrix[0][0] + c.matrix[0][1] * c.matrix[0][1]).sqrt();
|
||||
let centre = [apply(&c.matrix, o), apply(&c.matrix, [o[0], o[1], o[2] + h])];
|
||||
centre.iter().flat_map(|p| [[p[0] - r * s, p[1] - r * s, p[2]], [p[0] + r * s, p[1] + r * s, p[2]]]).collect()
|
||||
}
|
||||
s => s.corners().into_iter().map(|p| apply(&c.matrix, p)).collect(),
|
||||
};
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
let mut hi = [f64::NEG_INFINITY; 3];
|
||||
for p in pts {
|
||||
for k in 0..3 {
|
||||
lo[k] = lo[k].min(p[k]);
|
||||
hi[k] = hi[k].max(p[k]);
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn judge(name: &str, who: &str, got: Got) -> Got {
|
||||
let c = case(name);
|
||||
match (&c.fails, &got) {
|
||||
(Some(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
|
||||
(Some(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
|
||||
(None, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(None, Ok(m)) => {
|
||||
let v = c.shape.volume() * det(&c.matrix).abs();
|
||||
assert!(rel(m.volume, v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.volume);
|
||||
assert_eq!(m.counts, c.shape.counts(), "{name}: {who} counts");
|
||||
let (lo, hi) = expected_bounds(&c);
|
||||
let near = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6);
|
||||
assert!(near(m.bounds.0, lo) && near(m.bounds.1, hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds);
|
||||
}
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn solidcore_case(name: &str) -> Got {
|
||||
let c = case(name);
|
||||
let got = transform(&c.shape.core(), &c.matrix).map(|b| {
|
||||
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
Measured { volume: volume(&b, 1e-8).unwrap(), bounds: bounds(&b).unwrap(), counts: counts(&b).unwrap(), kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "solidcore", got)
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, called directly (whatever the choice).
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Got {
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let c = case(name);
|
||||
let body = match c.shape {
|
||||
Shape::Box(o, s) => ffi::make_box(&o, &s).unwrap(),
|
||||
Shape::Cylinder(o, r, h) => ffi::make_cylinder(&o, r, h).unwrap(),
|
||||
};
|
||||
let got = ffi::transform(&body, &c.matrix).map_err(error).map(|h| {
|
||||
let volume = ffi::volume(&h, 1e-8).unwrap();
|
||||
let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let bx = ffi::measure::bounds(&h).unwrap();
|
||||
let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Measured { volume, bounds: bx, counts: n, kinds: kinds(&rec) }
|
||||
});
|
||||
judge(name, "C++", got)
|
||||
}
|
||||
|
||||
/// solidcore and C++ agree: volume within 1e-9, the same counts, bounds and faces by kind, or the
|
||||
/// same error.
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &str) {
|
||||
let (ours, cpp) = (solidcore_case(name), cpp_case(name));
|
||||
match (ours, cpp) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
assert!(rel(a.volume, b.volume) < 1e-9, "{name}: solidcore {} vs C++ {}", a.volume, b.volume);
|
||||
assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)");
|
||||
assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)");
|
||||
let near = |x: [f64; 3], y: [f64; 3]| x.iter().zip(&y).all(|(p, q)| (p - q).abs() < 1e-6);
|
||||
assert!(near(a.bounds.0, b.bounds.0) && near(a.bounds.1, b.bounds.1), "{name}: bounds {:?} vs C++ {:?}", a.bounds, b.bounds);
|
||||
}
|
||||
(a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::cpp_case(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
cases!(
|
||||
identity,
|
||||
translation,
|
||||
rotation_about_z,
|
||||
uniform_scale,
|
||||
mirror_in_x,
|
||||
non_uniform_scale,
|
||||
shear,
|
||||
cylinder_moved,
|
||||
cylinder_rotated,
|
||||
cylinder_mirrored,
|
||||
cylinder_scaled,
|
||||
);
|
||||
|
||||
/// The capi's argument check, before any kernel call: every entry finite.
|
||||
#[test]
|
||||
fn hostile_matrices_are_refused() {
|
||||
let b = BOX.core();
|
||||
for (i, j) in [(0, 0), (1, 2), (3, 1), (2, 3)] {
|
||||
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut m = ID;
|
||||
m[i][j] = x;
|
||||
assert_eq!(transform(&b, &m).err(), Some(Error::Argument), "m[{i}][{j}] = {x}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A singular or huge matrix never panics: it gives a body or an error.
|
||||
#[test]
|
||||
fn degenerate_matrices_do_not_crash() {
|
||||
let b = BOX.core();
|
||||
let zero = [[0.0; 4]; 4];
|
||||
let flat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0; 4]];
|
||||
let huge = [[1e300, 0.0, 0.0, 0.0], [0.0, 1e300, 0.0, 0.0], [0.0, 0.0, 1e300, 0.0], [0.0; 4]];
|
||||
for m in [zero, flat, huge] {
|
||||
let _ = transform(&b, &m);
|
||||
}
|
||||
}
|
||||
|
||||
/// The body moved, and the body copied, are never changed.
|
||||
#[test]
|
||||
fn the_body_stays_as_it_was() {
|
||||
let b = BOX.core();
|
||||
let moved = transform(&b, &rotation_z(1.0)).unwrap();
|
||||
let c = copy(&b).unwrap();
|
||||
drop(moved);
|
||||
for x in [&b, &c] {
|
||||
assert_eq!(counts(x).unwrap(), [6, 12, 8]);
|
||||
assert!(rel(volume(x, 1e-8).unwrap(), 6000.0) < 1e-12);
|
||||
assert_eq!(bounds(x).unwrap(), ([1.0, 2.0, 3.0], [11.0, 22.0, 33.0]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Face and edge tags (`Brep::tagged`) through a copy and a transform on solidcore, as on
|
||||
/// C++: the box is tagged on the C++ kernel, its solidcore body converted from that and tagged the
|
||||
/// same way, and the two results' histories must be the same.
|
||||
#[cfg(feature = "native")]
|
||||
mod history {
|
||||
use crate::{Brep, ffi};
|
||||
|
||||
fn tagged_box() -> Brep {
|
||||
crate::init().unwrap();
|
||||
Brep::made(ffi::make_box(&[0.0; 3], &[20.0, 30.0, 10.0]).unwrap()).tagged(0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_copy() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = tagged_box();
|
||||
let cpp = Brep::made(ffi::copy(a.cpp().unwrap()).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::copy(a.core().unwrap()).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_cross_a_mirror_and_a_shear() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let a = tagged_box();
|
||||
let mirror = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0; 4]];
|
||||
let shear = [[1.0, 0.5, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [2.0, 3.0, 4.0, 1.0]];
|
||||
for m in [mirror, shear] {
|
||||
let cpp = Brep::made(ffi::transform(a.cpp().unwrap(), &m).unwrap()).history().unwrap();
|
||||
let core = Brep::made_core(super::transform(a.core().unwrap(), &m).unwrap()).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical(), "{m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,862 @@
|
||||
//! Untrim and extend on solidcore: the C API's `smlib_untrim_face` and `smlib_extend_sheet`,
|
||||
//! ported from the fork's `capi/src/smlib_untrim.cpp` (`face_at`, `loop_distance`,
|
||||
//! `copy_attributes`, `analytic`, `span_of`, `pieces_of`, `is_disc`, `rebuild_hole`) and
|
||||
//! `capi/src/smlib_extend.cpp` (`on_side`, `eval`, `nearest_edge`, `is_boundary`, `side_edge`,
|
||||
//! `across`, `cone_continued`, `same_patch`).
|
||||
//!
|
||||
//! A face restored towards its surface. Picked inner loops (holes) are taken away with
|
||||
//! `SmFace::DeleteInnerLoop` on a copy where they are lamina; the outer boundary goes back to a
|
||||
//! parameter box of the surface (`SmBrep::CreateFaceFromSurface`), and the holes kept are rebuilt
|
||||
//! in that face from their own edges' curves and intervals (`MakeVertexLoop`, `MakeEdgeInFace`:
|
||||
//! closing a loop splits off the face inside it, which is deleted). The new face and the rebuilt
|
||||
//! edges take the attributes of the face and edges they stand for, so the history tags carry
|
||||
//! through.
|
||||
//!
|
||||
//! A sheet is extended by SMLib's extension of its surface (`CreateExtendedSurface`: its end
|
||||
//! spans continued, or a G1 or G2 run-off), the distance asked of it corrected until the
|
||||
//! extension measures the distance across the side's middle; `CopyAndAddAnalytics` gives the
|
||||
//! result back its analytic kind, and a cone or cylinder continued naturally stays the analytic
|
||||
//! widened. The new face and each of its sides take the attributes of the face and side they
|
||||
//! continue.
|
||||
//!
|
||||
//! An untrim of a face out of its body (`in_place` false) copies the face into a body of its own
|
||||
//! through `SmBrep::CopyFaces` with the merge (solidcore#1872), which keeps its attributes.
|
||||
|
||||
use std::sync::PoisonError;
|
||||
|
||||
use solidcore::EntityRef;
|
||||
use solidcore::curve::curve::Curve;
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::geom_utility::p2::ContinuityType;
|
||||
use solidcore::geom::object::Shape;
|
||||
use solidcore::geom::tol::USE_DEFAULT;
|
||||
use solidcore::geom::vector2d::Point2;
|
||||
use solidcore::geom::vector3d::{Point3, Vec3};
|
||||
use solidcore::kernel::Kernel;
|
||||
use solidcore::nurbs::b_spline_surface::p1::SurfParamType;
|
||||
use solidcore::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags;
|
||||
use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
|
||||
use solidcore::query::api_queries::{edge_closest_point, face_closest_point_in_shape, face_internal_point};
|
||||
use solidcore::query::brep::{BrepQuery, LiveBrepObjects};
|
||||
use solidcore::surface::cone::p1::Cone;
|
||||
use solidcore::surface::surface::Surface;
|
||||
use solidcore::surface::surface::SurfaceKind;
|
||||
use solidcore::topology::brep::p1 as brep_p1;
|
||||
use solidcore::topology::brep::p2::{CopyNotifyState, copy_source_ref};
|
||||
use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face, new_brep, with_kernel_callbacks};
|
||||
use solidcore::topology::brep::p5::{EdgeInFace, make_edge_in_face};
|
||||
use solidcore::topology::brep::p7::copy_faces_from;
|
||||
use solidcore::topology::brep::p8::create_face_from_surface;
|
||||
use solidcore::topology::brep::p9::make_vertex_loop;
|
||||
use solidcore::topology::edgeuse::p1 as eu_p1;
|
||||
use solidcore::topology::edgeuse::p3::get_or_create_uv_trim_curve;
|
||||
use solidcore::topology::face::{p1 as face_p1, p2 as face_p2, p4 as face_p4};
|
||||
use solidcore::topology::loopuse::{loop_edges, loop_loopuses, notify_ref};
|
||||
use solidcore::topology::topology_traverser::Marks;
|
||||
use solidcore::topology::{Brep, BrepId, EdgeId, FaceId, LoopId, edge};
|
||||
|
||||
use super::blend::{p3, status};
|
||||
use super::sweep::build_faces;
|
||||
use super::{Body, answer, run};
|
||||
use crate::trim::Continuation;
|
||||
use crate::{Error, Result};
|
||||
use solidcore::topology::brep::p3::brep_copy;
|
||||
|
||||
/// `MAX_HOLE_POINTS`.
|
||||
const MAX_HOLE_POINTS: usize = 10_000;
|
||||
|
||||
/// `SMLIB_ERR_EXCEPTION`'s stand-in: a rebuilt hole that did not close as the capi expects.
|
||||
fn hole_refused() -> Error {
|
||||
Error::Exception
|
||||
}
|
||||
|
||||
/// The capi's statuses as SolidCraft errors: an operation's own result.
|
||||
type Out<T> = std::result::Result<T, Error>;
|
||||
|
||||
const ONE: BrepId = BrepId::from_index(0);
|
||||
|
||||
/// `face_at`: the face of `b` nearest `p`, within `tol` (`SmApiFaceClosestPoint`; a face whose
|
||||
/// query fails is skipped).
|
||||
fn face_at(k: &mut Kernel, b: &mut Brep, p: Point3, tol: f64) -> solidcore::Result<Option<FaceId>> {
|
||||
let all = brep_p1::faces(b, None)?;
|
||||
let mut shapes = Vec::with_capacity(all.len());
|
||||
for &f in &all {
|
||||
shapes.push(answer(Shape::new(k, &(ONE, &*b), &[], &[(ONE, f)], &[], &[]))?);
|
||||
}
|
||||
let query = BrepQuery::of_brep(b);
|
||||
let identity = query.effective_identity();
|
||||
let objects = LiveBrepObjects::new(b, &identity, None)?;
|
||||
let (mut best, mut d0) = (None, f64::INFINITY);
|
||||
for (&f, shape) in all.iter().zip(&shapes) {
|
||||
let Some(shape) = shape else { continue };
|
||||
if let Some(c) = answer(face_closest_point_in_shape(k, shape, &objects, (ONE, f), p))?
|
||||
&& c.distance.is_finite()
|
||||
&& c.distance < d0
|
||||
{
|
||||
d0 = c.distance;
|
||||
best = Some(f);
|
||||
}
|
||||
}
|
||||
Ok(best.filter(|_| d0 <= tol))
|
||||
}
|
||||
|
||||
/// `loop_distance`: the distance from `p` to the nearest edge of loop `l`.
|
||||
fn loop_distance(k: &Kernel, b: &Brep, l: LoopId, p: Point3) -> solidcore::Result<f64> {
|
||||
let mut best = f64::INFINITY;
|
||||
for e in loop_edges(b, l)?.into_iter().flatten() {
|
||||
if let Some(c) = answer(edge_closest_point(k, b, e, p))?
|
||||
&& c.closest.distance.is_finite()
|
||||
{
|
||||
best = best.min(c.closest.distance);
|
||||
}
|
||||
}
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// `copy_attributes`: `to` (in `into`) takes a copy of each attribute of `from` (in `src`).
|
||||
fn copy_attributes(src: &Brep, from: EntityRef, into: &mut Brep, to: EntityRef) -> solidcore::Result<()> {
|
||||
let copies: Vec<_> = src.entity_attributes(from).into_iter().map(|(_, a)| a.make_copy()).collect();
|
||||
for a in copies {
|
||||
into.add_attribute(to, a)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`copy_attributes`] within one body.
|
||||
fn copy_attributes_within(b: &mut Brep, from: EntityRef, to: EntityRef) -> solidcore::Result<()> {
|
||||
let copies: Vec<_> = b.entity_attributes(from).into_iter().map(|(_, a)| a.make_copy()).collect();
|
||||
for a in copies {
|
||||
b.add_attribute(to, a)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `span_of`: the box of the face's outer loop in the surface's parameters, the union of the
|
||||
/// tight boxes of its edgeuses' UV trim curves (made where the face has none attached yet).
|
||||
fn span_of(k: &Kernel, b: &mut Brep, outer: LoopId) -> solidcore::Result<Option<Extent2d>> {
|
||||
let (lu, _) = loop_loopuses(b, outer)?;
|
||||
let eus = eu_p1::loopuse_edgeuses(b, lu)?;
|
||||
let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]);
|
||||
let mut any = false;
|
||||
for eu in eus {
|
||||
let fetched = get_or_create_uv_trim_curve(k, b, eu, false, false, true)?;
|
||||
if fetched.status.is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
let (Some(uv), _) = eu_p1::uv_trim_curve(b, eu)? else { return Ok(None) };
|
||||
let Some(e) = answer(uv.calculate_tight_bounding_box(k, &uv.natural_interval()))? else { return Ok(None) };
|
||||
let (mn, mx) = (e.min(), e.max());
|
||||
lo = [lo[0].min(mn.x), lo[1].min(mn.y)];
|
||||
hi = [hi[0].max(mx.x), hi[1].max(mx.y)];
|
||||
any = true;
|
||||
}
|
||||
if !any || !(hi[0] > lo[0]) || !(hi[1] > lo[1]) || !(lo[0] + lo[1] + hi[0] + hi[1]).is_finite() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Extent2d::from_bounds(lo[0], lo[1], hi[0], hi[1])))
|
||||
}
|
||||
|
||||
/// One edge of a hole to rebuild: its edge, how the loop runs along it, its ends as the loop
|
||||
/// runs, its curve and interval (`Piece`).
|
||||
struct Piece {
|
||||
edge: EdgeId,
|
||||
orient: OrientType,
|
||||
from: Point3,
|
||||
to: Point3,
|
||||
curve: Curve,
|
||||
interval: solidcore::geom::extent1d::Extent1d,
|
||||
}
|
||||
|
||||
/// `pieces_of`; `None` where the capi gives up.
|
||||
fn pieces_of(b: &Brep, l: LoopId) -> solidcore::Result<Option<Vec<Piece>>> {
|
||||
let (lu, _) = loop_loopuses(b, l)?;
|
||||
let mut out = Vec::new();
|
||||
for eu in eu_p1::loopuse_edgeuses(b, lu)? {
|
||||
let Some(e) = eu_p1::edge(b, eu)? else { return Ok(None) };
|
||||
let ed = b.edge(e)?;
|
||||
let Some(curve) = ed.curve.clone() else { return Ok(None) };
|
||||
let (Some(v0), Some(v1)) = edge::start_end_vertices(b, e)? else { return Ok(None) };
|
||||
let same = eu_p1::orientation(b, eu)? == OrientType::Same;
|
||||
let (p0, p1) = (b.vertex(v0)?.point, b.vertex(v1)?.point);
|
||||
let (from, to) = if same { (p0, p1) } else { (p1, p0) };
|
||||
out.push(Piece { edge: e, orient: if same { OrientType::Same } else { OrientType::Opposite }, from, to, curve, interval: ed.interval });
|
||||
}
|
||||
Ok((!out.is_empty()).then_some(out))
|
||||
}
|
||||
|
||||
/// `is_disc`: whether `f` is bounded by one loop of exactly the edges `made`.
|
||||
fn is_disc(b: &Brep, f: FaceId, made: &[EdgeId]) -> solidcore::Result<bool> {
|
||||
let loops = face_p2::loops(b, f, false, None)?;
|
||||
let [l] = loops.as_slice() else { return Ok(false) };
|
||||
let edges: Vec<EdgeId> = loop_edges(b, *l)?.into_iter().flatten().collect();
|
||||
Ok(edges.len() == made.len() && edges.iter().all(|e| made.contains(e)))
|
||||
}
|
||||
|
||||
/// `rebuild_hole`: the hole `pieces` (edges of `src`) rebuilt in `face` of `r`: vertices and
|
||||
/// edges made in the face, the face the closed loop splits off inside it deleted. `face` is the
|
||||
/// face that keeps the rest.
|
||||
fn rebuild_hole(k: &mut Kernel, src: Option<&Brep>, r: &mut Brep, face: &mut FaceId, pieces: &[Piece]) -> solidcore::Result<Out<()>> {
|
||||
let Some(first_piece) = pieces.first() else { return Ok(Err(hole_refused())) };
|
||||
let editing = r.editing_enabled();
|
||||
r.set_editing_enabled(true);
|
||||
let tol = r.tolerance();
|
||||
// Each edit notifies through the kernel's callbacks with the body's own attributes
|
||||
// (`MakeEdgeInFace` does so itself), so the history tags follow.
|
||||
let vertex_loop = |k: &mut Kernel, r: &mut Brep, face: FaceId, at: Point3| {
|
||||
with_kernel_callbacks(k, r, |_, r, env| make_vertex_loop(r, face, at, OrientType::Opposite, env))
|
||||
};
|
||||
let done = (|| -> solidcore::Result<Out<()>> {
|
||||
let (_, first) = match vertex_loop(k, r, *face, first_piece.from) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
let mut at = first;
|
||||
let mut made = Vec::with_capacity(pieces.len());
|
||||
let mut split = None;
|
||||
for (i, p) in pieces.iter().enumerate() {
|
||||
let last = i + 1 == pieces.len();
|
||||
let next = if last {
|
||||
first
|
||||
} else {
|
||||
match vertex_loop(k, r, *face, p.to) {
|
||||
Ok((_, v)) => v,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
}
|
||||
};
|
||||
let curve = p.curve.copy(k)?;
|
||||
let mut out = EdgeInFace::default();
|
||||
if let Err(e) = make_edge_in_face(k, r, true, Some(*face), Some(at), Some(next), curve, None, p.interval, p.orient, tol, &mut out, None) {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
let Some(e) = out.edge else { return Ok(Err(Error::Empty)) };
|
||||
match src {
|
||||
Some(s) => copy_attributes(s, EntityRef::Edge(p.edge), r, EntityRef::Edge(e))?,
|
||||
None => copy_attributes_within(r, EntityRef::Edge(p.edge), EntityRef::Edge(e))?,
|
||||
}
|
||||
made.push(e);
|
||||
if let Some(nf) = out.face {
|
||||
if !last || split.is_some() {
|
||||
return Ok(Err(hole_refused()));
|
||||
}
|
||||
split = Some(nf);
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
let Some(split) = split else { return Ok(Err(hole_refused())) };
|
||||
// One of the two faces is the disc inside the hole: it goes, its edges stay on the other.
|
||||
let disc = if is_disc(r, split, &made)? {
|
||||
split
|
||||
} else if is_disc(r, *face, &made)? {
|
||||
*face
|
||||
} else {
|
||||
return Ok(Err(hole_refused()));
|
||||
};
|
||||
let rest = if disc == split { *face } else { split };
|
||||
let deleted = with_kernel_callbacks(k, r, |k, r, env| {
|
||||
let mut marks = Marks::default();
|
||||
let options = DeleteFaceOptions { delete_edges_vertices: false, ..DeleteFaceOptions::default() };
|
||||
delete_face(k, r, env, &mut marks, disc, options)
|
||||
});
|
||||
if let Err(e) = deleted {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
*face = rest;
|
||||
Ok(Ok(()))
|
||||
})();
|
||||
r.set_editing_enabled(editing);
|
||||
r.refresh_cached_topology();
|
||||
done
|
||||
}
|
||||
|
||||
/// The orientation of `f`'s upward faceuse.
|
||||
fn upward(b: &Brep, f: FaceId) -> solidcore::Result<Option<OrientType>> {
|
||||
Ok(match face_p2::upward_faceuse(b, f)? {
|
||||
Some(fu) => Some(b.faceuse(fu)?.orientation),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `smlib_untrim_face`: the face of `body` nearest `point` (within `tol`) restored towards its
|
||||
/// surface: the holes nearest `holes` (all of them for `None`) taken away and, with `outer`, its
|
||||
/// outer boundary back on the surface's parameter box. The face alone, or with `in_place` the
|
||||
/// whole body with it replaced.
|
||||
pub(crate) fn untrim_face(body: &Body, point: [f64; 3], tol: f64, holes: Option<&[[f64; 3]]>, outer: bool, in_place: bool) -> Result<Body> {
|
||||
let n_holes = holes.map(<[_]>::len);
|
||||
if !point.iter().all(|x| x.is_finite())
|
||||
|| !tol.is_finite()
|
||||
|| !(tol > 0.0)
|
||||
|| n_holes.is_some_and(|n| n > MAX_HOLE_POINTS)
|
||||
|| holes.is_some_and(|h| !h.iter().flatten().all(|x| x.is_finite()))
|
||||
|| (n_holes == Some(0) && !outer)
|
||||
{
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if body.0.is_poisoned() {
|
||||
return Err(Error::Internal("smlib_untrim_face: the body was left unfinished by an earlier panic".to_owned()));
|
||||
}
|
||||
let at = p3(point);
|
||||
run("smlib_untrim_face", |k| {
|
||||
let mut whole = {
|
||||
let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
super::dump::input(k, "brep", &g);
|
||||
brep_copy(k, &g)?
|
||||
};
|
||||
// The face to untrim, in a body of its own (its hole edges lamina there) or in the copy
|
||||
// of the whole body.
|
||||
let (mut w, f) = if in_place {
|
||||
let f = face_at(k, &mut whole, at, tol)?;
|
||||
(whole, f)
|
||||
} else {
|
||||
let Some(src) = face_at(k, &mut whole, at, tol)? else { return Ok(Err(Error::NoMatch { point: 0 })) };
|
||||
let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
w.set_tolerance(whole.tolerance());
|
||||
// Through SmMerge, with both bodies' attributes in the notifications, so the copy
|
||||
// carries its source's history tags (`SM_NO_COPY`).
|
||||
let copied = {
|
||||
let mut state = CopyNotifyState::with_attributes_of(&mut whole, &mut w)?;
|
||||
let this_face = |f: FaceId| notify_ref(EntityRef::Face(f));
|
||||
let copied = copy_faces_from(k, &mut whole, &[src], &mut w, true, ©_source_ref, &this_face, &mut state.env(None));
|
||||
state.restore_attributes(&mut whole, &mut w)?;
|
||||
copied
|
||||
};
|
||||
let copied = match copied {
|
||||
Ok((c, _)) => c,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
// The capi's `copied.GetSize() != 1`: `SMLIB_ERR_EMPTY`.
|
||||
let [f] = copied.as_slice() else { return Ok(Err(Error::Empty)) };
|
||||
w.refresh_cached_topology();
|
||||
(w, Some(*f))
|
||||
};
|
||||
let Some(f) = f else { return Ok(Err(Error::NoMatch { point: 0 })) };
|
||||
let loops = face_p2::loops(&w, f, false, None)?;
|
||||
if loops.is_empty() {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let inner = loops.get(1..).unwrap_or_default();
|
||||
// The holes taken away: all of them, or the one nearest each point.
|
||||
let mut removed: Vec<LoopId> = Vec::new();
|
||||
match holes {
|
||||
None => removed.extend_from_slice(inner),
|
||||
Some(points) => {
|
||||
for p in points {
|
||||
let p = p3(*p);
|
||||
let (mut best, mut d0) = (None, f64::INFINITY);
|
||||
for &l in inner {
|
||||
let d = loop_distance(k, &w, l, p)?;
|
||||
if d < d0 {
|
||||
d0 = d;
|
||||
best = Some(l);
|
||||
}
|
||||
}
|
||||
let Some(best) = best.filter(|_| d0 <= tol) else { return Ok(Err(Error::NoMatch { point: 0 })) };
|
||||
if !removed.contains(&best) {
|
||||
removed.push(best);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !outer {
|
||||
for &l in &removed {
|
||||
// A hole whose edges another face of the body uses is no hole of a sheet.
|
||||
let deleted = face_p1::delete_inner_loop_with_kernel_callbacks(k, &mut w, f, l);
|
||||
if deleted.is_err() {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
}
|
||||
w.refresh_cached_topology();
|
||||
return Ok(Ok(w));
|
||||
}
|
||||
// The outer boundary: the surface's parameter box (a free-form surface's own, an
|
||||
// analytic one's the span of the face).
|
||||
let Some(surf) = face_p2::surface(&w, f)?.cloned() else { return Ok(Err(Error::Unsupported)) };
|
||||
let mut bx = surf.natural_uv_domain_with_nurb(k)?;
|
||||
if !matches!(surf.kind(), SurfaceKind::BSpline(_)) {
|
||||
let Some(outer_loop) = loops.first() else { return Ok(Err(Error::Unsupported)) };
|
||||
match span_of(k, &mut w, *outer_loop)? {
|
||||
Some(s) => bx = s,
|
||||
None => return Ok(Err(Error::Unsupported)),
|
||||
}
|
||||
}
|
||||
let mut kept = Vec::new();
|
||||
for &l in inner {
|
||||
if removed.contains(&l) {
|
||||
continue;
|
||||
}
|
||||
match pieces_of(&w, l)? {
|
||||
Some(p) => kept.push(p),
|
||||
None => return Ok(Err(Error::Unsupported)),
|
||||
}
|
||||
}
|
||||
let copy = surf.copy(k)?;
|
||||
let was = upward(&w, f)?;
|
||||
let mut r = if in_place {
|
||||
None
|
||||
} else {
|
||||
let mut r = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
r.set_tolerance(w.tolerance());
|
||||
Some(r)
|
||||
};
|
||||
let target: &mut Brep = match r.as_mut() {
|
||||
Some(r) => r,
|
||||
None => &mut w,
|
||||
};
|
||||
let mut nf = match build_faces(target, |t, fenv| create_face_from_surface(k, t, Some(copy), &bx, fenv)) {
|
||||
Ok(nf) => nf,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
target.refresh_cached_topology();
|
||||
let _ = target;
|
||||
for hole in &kept {
|
||||
let done = match r.as_mut() {
|
||||
Some(r) => rebuild_hole(k, Some(&w), r, &mut nf, hole)?,
|
||||
None => rebuild_hole(k, None, &mut w, &mut nf, hole)?,
|
||||
};
|
||||
if let Err(e) = done {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
}
|
||||
match r.as_mut() {
|
||||
Some(r) => copy_attributes(&w, EntityRef::Face(f), r, EntityRef::Face(nf))?,
|
||||
None => copy_attributes_within(&mut w, EntityRef::Face(f), EntityRef::Face(nf))?,
|
||||
}
|
||||
// The face faces the way the old one did.
|
||||
let target: &mut Brep = match r.as_mut() {
|
||||
Some(r) => r,
|
||||
None => &mut w,
|
||||
};
|
||||
let now = upward(target, nf)?;
|
||||
if let (Some(a), Some(b)) = (was, now)
|
||||
&& a != b
|
||||
&& let Err(e) = face_p1::flip_face_orientation(target, nf)
|
||||
{
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
if let Some(r) = r {
|
||||
return Ok(Ok(r));
|
||||
}
|
||||
// The old face goes, with the edges and vertices only it used: an edit of the brep,
|
||||
// which SMLib allows only in edit mode.
|
||||
let editing = w.editing_enabled();
|
||||
w.set_editing_enabled(true);
|
||||
let deleted = with_kernel_callbacks(k, &mut w, |k, w, env| {
|
||||
let mut marks = Marks::default();
|
||||
let options = DeleteFaceOptions { delete_edges_vertices: true, ..DeleteFaceOptions::default() };
|
||||
delete_face(k, w, env, &mut marks, f, options)
|
||||
});
|
||||
w.set_editing_enabled(editing);
|
||||
w.refresh_cached_topology();
|
||||
if let Err(e) = deleted {
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
Ok(Ok(w))
|
||||
})?
|
||||
.map(Body::new)
|
||||
}
|
||||
|
||||
/// One side of a face's parameter box (`Side`): which, whether it is a u = const line (the face
|
||||
/// grows in u), and whether it is at the box's max.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Side {
|
||||
which: SurfParamType,
|
||||
u: bool,
|
||||
high: bool,
|
||||
}
|
||||
|
||||
/// `SIDES`.
|
||||
const SIDES: [Side; 4] = [
|
||||
Side { which: SurfParamType::UMin, u: true, high: false },
|
||||
Side { which: SurfParamType::UMax, u: true, high: true },
|
||||
Side { which: SurfParamType::VMin, u: false, high: false },
|
||||
Side { which: SurfParamType::VMax, u: false, high: true },
|
||||
];
|
||||
|
||||
/// `SAMPLES`.
|
||||
const SAMPLES: usize = 9;
|
||||
|
||||
/// `on_side`: the point of side `s` of `dom` at `t` in [0, 1] along it, in UV.
|
||||
fn on_side(dom: &Extent2d, s: Side, t: f64) -> Point2 {
|
||||
let (lo, hi) = (dom.min(), dom.max());
|
||||
if s.u {
|
||||
Point2::new(if s.high { hi.x } else { lo.x }, lo.y + t * (hi.y - lo.y))
|
||||
} else {
|
||||
Point2::new(lo.x + t * (hi.x - lo.x), if s.high { hi.y } else { lo.y })
|
||||
}
|
||||
}
|
||||
|
||||
/// `eval`: the point at `uv` with its u and v derivatives; `None` where it does not evaluate or
|
||||
/// is not finite.
|
||||
fn eval(k: &Kernel, surf: &Surface, uv: Point2) -> Option<(Vec3, Vec3, Vec3)> {
|
||||
let d = surf.evaluate(k, uv, 1, 1, EvaluateFlags::default()).ok()?;
|
||||
let p = *d.first()?.first()?;
|
||||
let du = *d.get(1)?.first()?;
|
||||
let dv = *d.first()?.get(1)?;
|
||||
(p.length().is_finite() && du.length().is_finite() && dv.length().is_finite()).then_some((p, du, dv))
|
||||
}
|
||||
|
||||
fn point_at(k: &Kernel, surf: &Surface, uv: Point2) -> Option<Vec3> {
|
||||
eval(k, surf, uv).map(|(p, _, _)| p)
|
||||
}
|
||||
|
||||
/// `edge_distance`.
|
||||
fn edge_distance(k: &Kernel, b: &Brep, e: EdgeId, p: Vec3) -> f64 {
|
||||
match edge_closest_point(k, b, e, p) {
|
||||
Ok(c) if c.closest.distance.is_finite() => c.closest.distance,
|
||||
_ => f64::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// `nearest_edge`: the distance to the nearest of `edges`, with that edge.
|
||||
fn nearest_edge(k: &Kernel, b: &Brep, edges: &[EdgeId], p: Vec3) -> (f64, Option<EdgeId>) {
|
||||
let (mut best, mut which) = (f64::INFINITY, None);
|
||||
for &e in edges {
|
||||
let d = edge_distance(k, b, e, p);
|
||||
if d < best {
|
||||
best = d;
|
||||
which = Some(e);
|
||||
}
|
||||
}
|
||||
(best, which)
|
||||
}
|
||||
|
||||
/// `is_boundary`: whether side `s` of the face's box is a side of the face (its samples on the
|
||||
/// face's edges), and whether it collapses to a point (a pole).
|
||||
fn is_boundary(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edges: &[EdgeId], tol: f64) -> (bool, bool) {
|
||||
let (Some(a), Some(z), Some(m)) =
|
||||
(point_at(k, surf, on_side(dom, s, 0.0)), point_at(k, surf, on_side(dom, s, 1.0)), point_at(k, surf, on_side(dom, s, 0.5)))
|
||||
else {
|
||||
return (false, false);
|
||||
};
|
||||
if (a - z).length() <= tol && (a - m).length() <= tol {
|
||||
return (true, true);
|
||||
}
|
||||
let all = (0..=SAMPLES).all(|i| match point_at(k, surf, on_side(dom, s, i as f64 / SAMPLES as f64)) {
|
||||
Some(p) => nearest_edge(k, b, edges, p).0 <= tol,
|
||||
None => false,
|
||||
});
|
||||
(all, false)
|
||||
}
|
||||
|
||||
/// `side_edge`: the edge of `edges` side `s` of `dom` runs along (nearest the side's middle), or
|
||||
/// `None` for a side collapsed to a point.
|
||||
fn side_edge(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edges: &[EdgeId], on: f64) -> Option<EdgeId> {
|
||||
let a = point_at(k, surf, on_side(dom, s, 0.0))?;
|
||||
let z = point_at(k, surf, on_side(dom, s, 1.0))?;
|
||||
let m = point_at(k, surf, on_side(dom, s, 0.5))?;
|
||||
if (a - z).length() <= on && (a - m).length() <= on {
|
||||
return None;
|
||||
}
|
||||
let (d, e) = nearest_edge(k, b, edges, m);
|
||||
e.filter(|_| d <= on)
|
||||
}
|
||||
|
||||
/// `across`: how far `ext` (the face's surface extended past side `s` of its box `dom`, on the
|
||||
/// same parameters) runs past that side, measured across it through the side's middle, by
|
||||
/// 16-point Gauss-Legendre quadrature on 64 pieces. Negative when it can't be evaluated.
|
||||
fn across(k: &Kernel, ext: &Surface, dom: &Extent2d, s: Side) -> f64 {
|
||||
const X: [f64; 8] = [
|
||||
0.0950125098376374,
|
||||
0.2816035507792589,
|
||||
0.4580167776572274,
|
||||
0.6178762444026438,
|
||||
0.755404408355003,
|
||||
0.8656312023878318,
|
||||
0.9445750230732326,
|
||||
0.9894009349916499,
|
||||
];
|
||||
const W: [f64; 8] = [
|
||||
0.1894506104550685,
|
||||
0.1826034150449236,
|
||||
0.1691565193950025,
|
||||
0.1495959888165767,
|
||||
0.1246289712555339,
|
||||
0.0951585116824928,
|
||||
0.0622535239386479,
|
||||
0.0271524594117541,
|
||||
];
|
||||
const PIECES: usize = 64;
|
||||
let Ok(bx) = ext.natural_uv_domain_with_nurb(k) else { return -1.0 };
|
||||
let mid = on_side(dom, s, 0.5);
|
||||
let from = if s.u { mid.x } else { mid.y };
|
||||
let to = match (s.u, s.high) {
|
||||
(true, true) => bx.max().x,
|
||||
(true, false) => bx.min().x,
|
||||
(false, true) => bx.max().y,
|
||||
(false, false) => bx.min().y,
|
||||
};
|
||||
let h = (to - from) / PIECES as f64;
|
||||
let mut length = 0.0;
|
||||
for piece in 0..PIECES {
|
||||
let c = from + (piece as f64 + 0.5) * h;
|
||||
for i in 0..16 {
|
||||
let (x, w) = if i < 8 { (-X[i], W[i]) } else { (X[i - 8], W[i - 8]) };
|
||||
let t = c + 0.5 * h * x;
|
||||
let uv = if s.u { Point2::new(t, mid.y) } else { Point2::new(mid.x, t) };
|
||||
let Some((_, du, dv)) = eval(k, ext, uv) else { return -1.0 };
|
||||
length += 0.5 * h.abs() * w * if s.u { du } else { dv }.length();
|
||||
}
|
||||
}
|
||||
length
|
||||
}
|
||||
|
||||
/// `cone_continued`: a cone's or cylinder's face continued naturally `distance` past side `s` of
|
||||
/// its box `dom`, as the analytic itself over a wider STEP domain (degrees round the axis,
|
||||
/// height along it). `None` where the cone can't be widened so (more than a turn, or past the
|
||||
/// apex).
|
||||
fn cone_continued(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, distance: f64) -> Option<Surface> {
|
||||
let SurfaceKind::Cone(cone) = surf.kind() else { return None };
|
||||
let mut cone: Cone = cone.as_ref().clone();
|
||||
let step = |uv: Point2| cone.convert_uv_from_nurbs_to_step(k, uv).ok();
|
||||
let (a, z, m) = (step(on_side(dom, s, 0.0))?, step(on_side(dom, s, 1.0))?, step(on_side(dom, s, 0.5))?);
|
||||
let (lo, hi) = (step(dom.min())?, step(dom.max())?);
|
||||
let semi = cone.canonical().semi_angle_deg;
|
||||
let mut bx = [[lo.x.min(hi.x), lo.x.max(hi.x)], [lo.y.min(hi.y), lo.y.max(hi.y)]];
|
||||
let eps = 1e-9 * (1.0 + m.x.abs() + m.y.abs());
|
||||
// The STEP coordinate constant along the side is the one the face grows in.
|
||||
let c = if (a.x - z.x).abs() <= eps {
|
||||
0
|
||||
} else if (a.y - z.y).abs() <= eps {
|
||||
1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let at = if c == 0 { m.x } else { m.y };
|
||||
let row = bx.get_mut(c)?;
|
||||
let end = if (at - row[1]).abs() <= (at - row[0]).abs() { 1 } else { 0 };
|
||||
let by = if c == 0 {
|
||||
let r = cone.radius(m.y);
|
||||
if !(r > 0.0) {
|
||||
return None;
|
||||
}
|
||||
(distance / r).to_degrees()
|
||||
} else {
|
||||
distance * semi.to_radians().cos()
|
||||
};
|
||||
if end == 1 {
|
||||
row[1] += by;
|
||||
} else {
|
||||
row[0] -= by;
|
||||
}
|
||||
if bx[0][1] - bx[0][0] > 360.0 + 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let wider = Extent2d::from_bounds(bx[0][0], bx[1][0], bx[0][1], bx[1][1]);
|
||||
cone.adjust_step_uv_domain(k, &wider).ok()?;
|
||||
Some(Surface::from(cone))
|
||||
}
|
||||
|
||||
/// `same_patch`: whether `p` and `q` span the same patch: their boxes' corners the same points,
|
||||
/// in any order, within `tol`.
|
||||
fn same_patch(k: &Kernel, p: &Surface, q: &Surface, tol: f64) -> bool {
|
||||
let (Ok(bp), Ok(bq)) = (p.natural_uv_domain_with_nurb(k), q.natural_uv_domain_with_nurb(k)) else { return false };
|
||||
let corners = |b: &Extent2d| {
|
||||
let (lo, hi) = (b.min(), b.max());
|
||||
[lo, Point2::new(hi.x, lo.y), hi, Point2::new(lo.x, hi.y)]
|
||||
};
|
||||
corners(&bp).into_iter().all(|u| {
|
||||
let Some(x) = point_at(k, p, u) else { return false };
|
||||
corners(&bq).into_iter().any(|v| point_at(k, q, v).is_some_and(|y| (x - y).length() <= tol))
|
||||
})
|
||||
}
|
||||
|
||||
/// `extended_by`: the surface `surf` (whose face is its whole box `dom`) extended past side `s` as
|
||||
/// `ct` says, so that it runs `distance` past that side measured across it ([`across`]), within
|
||||
/// 1e-9 of it. `CreateExtendedSurface` takes its distance as an estimate, and how far it then runs
|
||||
/// is increasing in what it is asked: a bracketed solve on that (each ask scaled by the distance
|
||||
/// over what it gave until two asks fall either side, then Illinois false position). An extension
|
||||
/// that moves the face's side off its parameters, or a target not met, is [`Error::NotExact`],
|
||||
/// never the surface as it came out.
|
||||
fn extended_by(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, ct: ContinuityType, distance: f64, model_tol: f64) -> Result<Surface> {
|
||||
const BRACKET_TRIES: usize = 40;
|
||||
const SOLVE_TRIES: usize = 100;
|
||||
let tol = 1e-9 * distance;
|
||||
// How far asking `ask` runs, and the surface it makes.
|
||||
let try_ask = |ask: f64| -> Result<(Surface, f64)> {
|
||||
let tried = surf.create_extended_surface_dir(k, s.which, ask, ct).map_err(status)?;
|
||||
// The face's side stays where it was on the extended surface's parameters.
|
||||
let mid = on_side(dom, s, 0.5);
|
||||
let stays = matches!((point_at(k, surf, mid), point_at(k, &tried, mid)), (Some(was), Some(now)) if (was - now).length() <= model_tol);
|
||||
if !stays {
|
||||
return Err(Error::NotExact);
|
||||
}
|
||||
let got = across(k, &tried, dom, s);
|
||||
if got.is_finite() && got > 0.0 { Ok((tried, got)) } else { Err(Error::NotExact) }
|
||||
};
|
||||
// Asks that run short of and past `distance`, by f = got - distance.
|
||||
let (mut short, mut past) = (None, None);
|
||||
let mut ask = distance;
|
||||
for _ in 0..BRACKET_TRIES {
|
||||
let (made, got) = try_ask(ask)?;
|
||||
let f = got - distance;
|
||||
if f.abs() <= tol {
|
||||
return Ok(made);
|
||||
}
|
||||
if f < 0.0 {
|
||||
short = Some((ask, f));
|
||||
} else {
|
||||
past = Some((ask, f));
|
||||
}
|
||||
if short.is_some() && past.is_some() {
|
||||
break;
|
||||
}
|
||||
ask *= distance / got;
|
||||
}
|
||||
let (Some((mut lo, mut flo)), Some((mut hi, mut fhi))) = (short, past) else { return Err(Error::NotExact) };
|
||||
// Illinois: false position, halving the value at the end that stays when the same end moves
|
||||
// twice running.
|
||||
#[derive(PartialEq)]
|
||||
enum Moved {
|
||||
Neither,
|
||||
Lo,
|
||||
Hi,
|
||||
}
|
||||
let mut moved = Moved::Neither;
|
||||
for _ in 0..SOLVE_TRIES {
|
||||
let x = hi - fhi * (hi - lo) / (fhi - flo);
|
||||
if !(x > lo.min(hi) && x < lo.max(hi)) {
|
||||
break;
|
||||
}
|
||||
let (made, got) = try_ask(x)?;
|
||||
let f = got - distance;
|
||||
if f.abs() <= tol {
|
||||
return Ok(made);
|
||||
}
|
||||
if f < 0.0 {
|
||||
(lo, flo) = (x, f);
|
||||
if moved == Moved::Lo {
|
||||
fhi *= 0.5;
|
||||
}
|
||||
moved = Moved::Lo;
|
||||
} else {
|
||||
(hi, fhi) = (x, f);
|
||||
if moved == Moved::Hi {
|
||||
flo *= 0.5;
|
||||
}
|
||||
moved = Moved::Hi;
|
||||
}
|
||||
}
|
||||
Err(Error::NotExact)
|
||||
}
|
||||
|
||||
/// `smlib_extend_sheet`: the one-face sheet `sheet`, whose face is its surface's whole parameter
|
||||
/// box, continued `distance` past the side its edge nearest `point` (within `tol`) runs along,
|
||||
/// as `how` says. A sheet of another shape is [`Error::Unsupported`]; an extension that cannot be
|
||||
/// made `distance` long across the side (within 1e-9 of it) is [`Error::NotExact`].
|
||||
pub(crate) fn extend_sheet(sheet: &Body, point: [f64; 3], tol: f64, distance: f64, how: Continuation) -> Result<Body> {
|
||||
if !point.iter().all(|x| x.is_finite()) || !tol.is_finite() || !(tol > 0.0) || !distance.is_finite() || !(distance > 0.0) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if sheet.0.is_poisoned() {
|
||||
return Err(Error::Internal("smlib_extend_sheet: the body was left unfinished by an earlier panic".to_owned()));
|
||||
}
|
||||
run("smlib_extend_sheet", |k| {
|
||||
let b = {
|
||||
let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
super::dump::input(k, "sheet", &g);
|
||||
brep_copy(k, &g)?
|
||||
};
|
||||
let faces = brep_p1::faces(&b, None)?;
|
||||
let [f] = faces.as_slice() else { return Ok(Err(Error::Unsupported)) };
|
||||
let f = *f;
|
||||
let Some(surf) = face_p2::surface(&b, f)?.cloned() else { return Ok(Err(Error::Unsupported)) };
|
||||
if face_p2::loops(&b, f, false, None)?.len() != 1 {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let edges = face_p4::edges(&b, f, None, false, None)?;
|
||||
let (d, picked) = nearest_edge(k, &b, &edges, p3(point));
|
||||
let Some(picked) = picked.filter(|_| d <= tol) else { return Ok(Err(Error::NoMatch { point: 0 })) };
|
||||
let model_tol = b.tolerance().max(1e-9);
|
||||
let on = 10.0 * model_tol;
|
||||
let dom = face_p2::uv_domain(&b, f)?;
|
||||
// Every side of the box is a side of the face (the face is its whole box), and the picked
|
||||
// edge is all of one side.
|
||||
let mut grow = None;
|
||||
for s in SIDES {
|
||||
let (boundary, collapsed) = is_boundary(k, &b, &surf, &dom, s, &edges, on);
|
||||
if !boundary {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
if collapsed {
|
||||
continue;
|
||||
}
|
||||
let all = (0..=SAMPLES).all(|i| match point_at(k, &surf, on_side(&dom, s, i as f64 / SAMPLES as f64)) {
|
||||
Some(p) => edge_distance(k, &b, picked, p) <= on,
|
||||
None => false,
|
||||
});
|
||||
if all {
|
||||
grow = Some(s);
|
||||
}
|
||||
}
|
||||
let Some(grow) = grow else { return Ok(Err(Error::Unsupported)) };
|
||||
// SMLib extends the whole surface; the face must be all of it.
|
||||
let natural = surf.natural_uv_domain_with_nurb(k)?;
|
||||
let (nl, nh, dl, dh) = (natural.min(), natural.max(), dom.min(), dom.max());
|
||||
let ptol = 1e-9 * 1f64.max((nh.x - nl.x).max(nh.y - nl.y));
|
||||
if (nl.x - dl.x).abs() > ptol || (nl.y - dl.y).abs() > ptol || (nh.x - dh.x).abs() > ptol || (nh.y - dh.y).abs() > ptol {
|
||||
return Ok(Err(Error::Unsupported));
|
||||
}
|
||||
let ct = match how {
|
||||
Continuation::Tangent => ContinuityType::G1,
|
||||
Continuation::Curvature => ContinuityType::G1G2,
|
||||
Continuation::Natural => ContinuityType::CInfinity,
|
||||
};
|
||||
// CreateExtendedSurface takes its distance as an estimate (a natural parabola falls 2.5%
|
||||
// short, a G2 run-off of a circle runs more than twice as far): the distance asked of it
|
||||
// is solved for until the extension measures `distance` across the side's middle.
|
||||
let extended = match extended_by(k, &surf, &dom, grow, ct, distance, model_tol) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
// A cone or cylinder continued naturally stays one, as the analytic widened, else SMLib
|
||||
// gives it back its analytic kind where it can.
|
||||
let grown = match how {
|
||||
Continuation::Natural => cone_continued(k, &surf, &dom, grow, distance).filter(|g| same_patch(k, g, &extended, 10.0 * model_tol)),
|
||||
_ => None,
|
||||
};
|
||||
let grown = match grown {
|
||||
Some(g) => g,
|
||||
None => match extended.copy_and_add_analytics(k) {
|
||||
Ok(g) => g,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
},
|
||||
};
|
||||
let bx = grown.natural_uv_domain_with_nurb(k)?;
|
||||
let mut r = new_brep(k, USE_DEFAULT, USE_DEFAULT)?;
|
||||
r.set_tolerance(b.tolerance());
|
||||
let grown_eval = grown.copy(k)?;
|
||||
let nf = match build_faces(&mut r, |t, fenv| create_face_from_surface(k, t, Some(grown), &bx, fenv)) {
|
||||
Ok(nf) => nf,
|
||||
Err(e) => return Ok(Err(status(e))),
|
||||
};
|
||||
r.refresh_cached_topology();
|
||||
// The face and each side stand for the face and side they continue.
|
||||
copy_attributes(&b, EntityRef::Face(f), &mut r, EntityRef::Face(nf))?;
|
||||
let made = face_p4::edges(&r, nf, None, false, None)?;
|
||||
let new_on = 10.0 * r.tolerance().max(1e-9);
|
||||
for s in SIDES {
|
||||
let from = side_edge(k, &b, &surf, &dom, s, &edges, on);
|
||||
let to = side_edge(k, &r, &grown_eval, &bx, s, &made, new_on);
|
||||
if let (Some(from), Some(to)) = (from, to) {
|
||||
copy_attributes(&b, EntityRef::Edge(from), &mut r, EntityRef::Edge(to))?;
|
||||
}
|
||||
}
|
||||
// The new face faces the way the old one did.
|
||||
if let (Some(was), Some(now)) = (upward(&b, f)?, upward(&r, nf)?)
|
||||
&& was != now
|
||||
&& let Err(e) = face_p1::flip_face_orientation(&mut r, nf)
|
||||
{
|
||||
return Ok(Err(status(e)));
|
||||
}
|
||||
Ok(Ok(r))
|
||||
})?
|
||||
.map(Body::new)
|
||||
}
|
||||
|
||||
/// A point inside each face, in the body's face order (`smlib_brep_face_points`:
|
||||
/// `SmFace::CalculateAnInternalPoint`).
|
||||
pub(crate) fn face_points(body: &Body) -> Result<Vec<[f64; 3]>> {
|
||||
super::with_body("smlib_brep_face_points", body, |k, b| {
|
||||
let faces = b.face_list().to_vec();
|
||||
faces.into_iter().map(|f| face_internal_point(k, b, f).map(|p| [p.x, p.y, p.z])).collect()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Untrim and extend on solidcore, against closed forms: areas and counts, and every result exports to a
|
||||
//! record that validates. With SMLib built, the same input converted to the C++ kernel gives the
|
||||
//! same result or the same error (parity only: the expected values are the analytic ones).
|
||||
//!
|
||||
//! An untrim's body is a 20 x 20 x 5 plate with a 3 mm through hole at (10, 10), made on
|
||||
//! solidcore; its top face is a square with a round hole. An extend's is a one-face sheet that is
|
||||
//! its surface's whole parameter box: a 10 mm square, or a quarter of a cylinder of radius 10 and
|
||||
//! height 10.
|
||||
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, PI};
|
||||
|
||||
use super::trim::{extend_sheet, untrim_face};
|
||||
use super::{Body, area, counts, make_box, make_cylinder, to_arrays};
|
||||
use crate::export::native;
|
||||
use crate::trim::Continuation;
|
||||
use crate::{BooleanOp, Error};
|
||||
|
||||
const TOL: f64 = 0.01;
|
||||
/// A point on the top face, away from the hole.
|
||||
const TOP: [f64; 3] = [2.0, 2.0, 5.0];
|
||||
/// A point on the hole's rim on the top face.
|
||||
const RIM: [f64; 3] = [13.0, 10.0, 5.0];
|
||||
|
||||
/// The plate's top as a sheet of its own: a 20 mm square at z = 0 with a 3 mm hole at (10, 10).
|
||||
fn holed_sheet() -> Body {
|
||||
use crate::sweeps::Curve;
|
||||
let line = |a: [f64; 3], b: [f64; 3]| Curve::Line { start: a, end: b };
|
||||
let (a, b, c, d) = ([0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 20.0, 0.0], [0.0, 20.0, 0.0]);
|
||||
let hole = Curve::Arc { center: [10.0, 10.0, 0.0], axis: [0.0, 0.0, 1.0], reference: [1.0, 0.0, 0.0], radius: 3.0, start: 0.0, end: 2.0 * PI };
|
||||
super::sweep::planar_faces(&[line(a, b), line(b, c), line(c, d), line(d, a), hole]).unwrap()
|
||||
}
|
||||
|
||||
/// On the sheet: a point away from the hole, and one on the hole's rim.
|
||||
const SHEET: [f64; 3] = [2.0, 2.0, 0.0];
|
||||
const SHEET_RIM: [f64; 3] = [13.0, 10.0, 0.0];
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Input {
|
||||
Plate,
|
||||
Sheet,
|
||||
}
|
||||
|
||||
impl Input {
|
||||
fn core(self) -> Body {
|
||||
match self {
|
||||
Input::Plate => plate(),
|
||||
Input::Sheet => holed_sheet(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plate() -> Body {
|
||||
let block = make_box([0.0; 3], [20.0, 20.0, 5.0]).unwrap();
|
||||
let pin = make_cylinder([10.0, 10.0, -1.0], 3.0, 7.0).unwrap();
|
||||
super::boolean::boolean(&block, &pin, BooleanOp::Subtract).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Case {
|
||||
input: Input,
|
||||
point: [f64; 3],
|
||||
/// `None`: every hole.
|
||||
holes: Option<&'static [[f64; 3]]>,
|
||||
outer: bool,
|
||||
in_place: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Expect {
|
||||
/// A sheet: its area and face count.
|
||||
Sheet(f64, usize),
|
||||
Fails(Error),
|
||||
}
|
||||
|
||||
fn case(name: &str) -> (Case, Expect) {
|
||||
let c = |input, point, holes, outer, in_place| Case { input, point, holes, outer, in_place };
|
||||
let square = 400.0;
|
||||
let holed = 400.0 - 9.0 * PI;
|
||||
let (plate, sheet) = (Input::Plate, Input::Sheet);
|
||||
match name {
|
||||
// The plate's top face, alone.
|
||||
"every_hole_taken_away" => (c(plate, TOP, None, false, false), Expect::Sheet(square, 1)),
|
||||
"the_hole_at_a_point_taken_away" => (c(plate, TOP, Some(&[RIM]), false, false), Expect::Sheet(square, 1)),
|
||||
// The outer boundary back on the plane's box of the face's span, the hole kept.
|
||||
"outer_boundary_back_hole_kept" => (c(plate, TOP, Some(&[]), true, false), Expect::Sheet(holed, 1)),
|
||||
"outer_boundary_back_holes_gone" => (c(plate, TOP, None, true, false), Expect::Sheet(square, 1)),
|
||||
// In the solid, the hole's edges are the bore's too: no hole of a sheet.
|
||||
"a_hole_of_a_solid_in_place" => (c(plate, TOP, None, false, true), Expect::Fails(Error::Unsupported)),
|
||||
"a_point_near_no_face" => (c(plate, [50.0, 50.0, 50.0], None, false, false), Expect::Fails(Error::NoMatch { point: 0 })),
|
||||
// The holed sheet, in place.
|
||||
"sheet_every_hole_taken_away" => (c(sheet, SHEET, None, false, true), Expect::Sheet(square, 1)),
|
||||
"sheet_the_hole_at_a_point_taken_away" => (c(sheet, SHEET, Some(&[SHEET_RIM]), false, true), Expect::Sheet(square, 1)),
|
||||
"sheet_outer_boundary_back_hole_kept" => (c(sheet, SHEET, Some(&[]), true, true), Expect::Sheet(holed, 1)),
|
||||
"sheet_outer_boundary_back_holes_gone" => (c(sheet, SHEET, None, true, true), Expect::Sheet(square, 1)),
|
||||
"sheet_a_point_near_no_hole" => (c(sheet, SHEET, Some(&[SHEET]), false, true), Expect::Fails(Error::NoMatch { point: 0 })),
|
||||
_ => panic!("no case {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Measured {
|
||||
area: f64,
|
||||
counts: [usize; 3],
|
||||
}
|
||||
|
||||
type Got = std::result::Result<Measured, Error>;
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs()
|
||||
}
|
||||
|
||||
fn judge(name: &str, who: &str, got: Got) -> Got {
|
||||
let (_, expect) = case(name);
|
||||
match (&expect, &got) {
|
||||
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
|
||||
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
|
||||
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(Expect::Sheet(a, faces), Ok(m)) => {
|
||||
assert!(rel(m.area, *a) < 1e-9, "{name}: {who} area {} vs analytic {a}", m.area);
|
||||
assert_eq!(m.counts[0], *faces, "{name}: {who} faces");
|
||||
}
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn solidcore_case(name: &str) -> Got {
|
||||
let (c, _) = case(name);
|
||||
let got = untrim_face(&c.input.core(), c.point, TOL, c.holes, c.outer, c.in_place).map(|b| {
|
||||
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
Measured { area: area(&b, 1e-8).unwrap(), counts: counts(&b).unwrap() }
|
||||
});
|
||||
judge(name, "solidcore", got)
|
||||
}
|
||||
|
||||
/// The case on the C++ kernel, called directly (whatever the choice), on the plate converted
|
||||
/// from solidcore's.
|
||||
#[cfg(feature = "native")]
|
||||
fn cpp_case(name: &str) -> Got {
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let (c, _) = case(name);
|
||||
let body = Brep::made_core(c.input.core());
|
||||
let r = ffi::trim::untrim_face(body.cpp().unwrap(), &c.point, TOL, c.holes, c.outer, c.in_place).map_err(|s| match s {
|
||||
ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 },
|
||||
s => error(s),
|
||||
});
|
||||
let got = r.map(|h| {
|
||||
let a = ffi::measure::area(&h, 1e-8).unwrap();
|
||||
let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Measured { area: a, counts: n }
|
||||
});
|
||||
judge(name, "C++", got)
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn parity(name: &str) {
|
||||
match (solidcore_case(name), cpp_case(name)) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
assert!(rel(a.area, b.area) < 1e-9, "{name}: solidcore {} vs C++ {}", a.area, b.area);
|
||||
assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)");
|
||||
}
|
||||
(a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::solidcore_case(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::cpp_case(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
cases!(
|
||||
every_hole_taken_away,
|
||||
the_hole_at_a_point_taken_away,
|
||||
outer_boundary_back_hole_kept,
|
||||
outer_boundary_back_holes_gone,
|
||||
a_hole_of_a_solid_in_place,
|
||||
a_point_near_no_face,
|
||||
sheet_every_hole_taken_away,
|
||||
sheet_the_hole_at_a_point_taken_away,
|
||||
sheet_outer_boundary_back_hole_kept,
|
||||
sheet_outer_boundary_back_holes_gone,
|
||||
sheet_a_point_near_no_hole,
|
||||
);
|
||||
|
||||
/// The capi's argument checks, before any kernel call.
|
||||
#[test]
|
||||
fn hostile_arguments_are_refused() {
|
||||
let b = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let p = [5.0, 5.0, 10.0];
|
||||
assert_eq!(untrim_face(&b, [f64::NAN, 0.0, 0.0], TOL, None, true, false).err(), Some(Error::Argument));
|
||||
for t in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(untrim_face(&b, p, t, None, true, false).err(), Some(Error::Argument), "tolerance {t}");
|
||||
}
|
||||
assert_eq!(untrim_face(&b, p, TOL, Some(&[]), false, false).err(), Some(Error::Argument), "nothing to do");
|
||||
assert_eq!(untrim_face(&b, p, TOL, Some(&[[f64::INFINITY; 3]]), false, false).err(), Some(Error::Argument));
|
||||
let many = vec![p; 10_001];
|
||||
assert_eq!(untrim_face(&b, p, TOL, Some(&many), false, false).err(), Some(Error::Argument));
|
||||
}
|
||||
|
||||
/// The body untrimmed is never changed.
|
||||
#[test]
|
||||
fn the_body_stays_as_it_was() {
|
||||
let b = plate();
|
||||
let was = counts(&b).unwrap();
|
||||
let _ = untrim_face(&b, TOP, TOL, None, true, false);
|
||||
let _ = untrim_face(&b, TOP, TOL, None, false, true);
|
||||
assert_eq!(counts(&b).unwrap(), was);
|
||||
}
|
||||
|
||||
/// Face and edge tags (`Brep::tagged`) through an untrim on solidcore, as on C++.
|
||||
#[cfg(feature = "native")]
|
||||
mod history {
|
||||
use crate::{Brep, ffi};
|
||||
|
||||
#[test]
|
||||
fn tags_cross_an_untrim() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
crate::init().unwrap();
|
||||
let a = Brep::made_core(super::holed_sheet()).tagged(0).unwrap();
|
||||
for (holes, outer) in [(None, false), (Some(&[][..]), true)] {
|
||||
let cpp = ffi::trim::untrim_face(a.cpp().unwrap(), &super::SHEET, super::TOL, holes, outer, true).unwrap();
|
||||
let cpp = Brep::made(cpp).history().unwrap();
|
||||
let core = super::untrim_face(a.core().unwrap(), super::SHEET, super::TOL, holes, outer, true).unwrap();
|
||||
let core = Brep::made_core(core).history().unwrap();
|
||||
assert_eq!(core.canonical(), cpp.canonical(), "holes {holes:?}, outer {outer}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-face sheet on `surface` over `domain`, the face its whole box.
|
||||
fn whole_face(surface: solidcore::surface::surface::Surface) -> Body {
|
||||
use solidcore::topology::brep::p3::new_brep;
|
||||
use solidcore::topology::brep::p8::create_face_from_surface;
|
||||
super::run("sheet", |k| {
|
||||
let mut b = new_brep(k, solidcore::geom::tol::USE_DEFAULT, solidcore::geom::tol::USE_DEFAULT)?;
|
||||
let domain = surface.natural_uv_domain_with_nurb(k)?;
|
||||
super::sweep::build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(surface), &domain, fenv))?;
|
||||
b.refresh_cached_topology();
|
||||
Ok(b)
|
||||
})
|
||||
.map(Body::new)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Sheet {
|
||||
/// [0, 10]^2 at z = 0.
|
||||
Square,
|
||||
/// Radius 10 about +Z, from 0 to 90 degrees, z from 0 to 10.
|
||||
QuarterCylinder,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
fn core(self) -> Body {
|
||||
use solidcore::geom::extent2d::Extent2d;
|
||||
use solidcore::geom::vector2d::Vec2;
|
||||
use solidcore::geom::vector3d::Vec3;
|
||||
use solidcore::surface::cone::p1::Cone;
|
||||
use solidcore::surface::plane::p1::Plane;
|
||||
use solidcore::surface::surface::Surface;
|
||||
let (o, x, y) = (Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0));
|
||||
match self {
|
||||
Sheet::Square => whole_face(Surface::from(Plane::new(o, x, y, Vec2::new(1.0, 1.0), &Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0)))),
|
||||
Sheet::QuarterCylinder => whole_face(Surface::from(Cone::new_cylinder_bounded(o, x, y, 10.0, 0.0, 90.0, 10.0, false, false, false))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_case(name: &str) -> (Sheet, [f64; 3], f64, Continuation, Expect) {
|
||||
let quarter = 5.0 * PI;
|
||||
match name {
|
||||
"extend_a_square" => (Sheet::Square, [10.0, 5.0, 0.0], 5.0, Continuation::Natural, Expect::Sheet(150.0, 1)),
|
||||
"extend_a_square_tangent" => (Sheet::Square, [5.0, 10.0, 0.0], 5.0, Continuation::Tangent, Expect::Sheet(150.0, 1)),
|
||||
"extend_a_square_curvature" => (Sheet::Square, [0.0, 5.0, 0.0], 2.0, Continuation::Curvature, Expect::Sheet(120.0, 1)),
|
||||
"extend_a_cylinder_along_its_axis" => {
|
||||
(Sheet::QuarterCylinder, [FRAC_1_SQRT_2 * 10.0, FRAC_1_SQRT_2 * 10.0, 10.0], 5.0, Continuation::Natural, Expect::Sheet(quarter * 15.0, 1))
|
||||
}
|
||||
"extend_a_cylinder_round_its_axis" => {
|
||||
(Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Natural, Expect::Sheet(10.0 * (quarter + 5.0), 1))
|
||||
}
|
||||
// Run-offs 5 long across the surface whatever their curvature: the strip stays its section
|
||||
// swept 10 along z. A circle's G2 run-off runs about 2.3 times what it is asked and more
|
||||
// than doubles with it (fork 0f7b209), so the ask must be solved for.
|
||||
"extend_a_cylinder_tangent_run_off" => {
|
||||
(Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Tangent, Expect::Sheet(10.0 * (quarter + 5.0), 1))
|
||||
}
|
||||
"extend_a_cylinder_curvature_run_off" => {
|
||||
(Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Curvature, Expect::Sheet(10.0 * (quarter + 5.0), 1))
|
||||
}
|
||||
"extend_picks_near_no_edge" => (Sheet::Square, [50.0, 50.0, 0.0], 5.0, Continuation::Natural, Expect::Fails(Error::NoMatch { point: 0 })),
|
||||
_ => panic!("no case {name}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn extend_judge(name: &str, who: &str, got: Got) -> Got {
|
||||
let (.., expect) = extend_case(name);
|
||||
match (&expect, &got) {
|
||||
(Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"),
|
||||
(Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"),
|
||||
(_, Err(g)) => panic!("{name}: {who} failed: {g}"),
|
||||
(Expect::Sheet(a, faces), Ok(m)) => {
|
||||
assert!(rel(m.area, *a) < 1e-6, "{name}: {who} area {} vs analytic {a}", m.area);
|
||||
assert_eq!(m.counts[0], *faces, "{name}: {who} faces");
|
||||
}
|
||||
}
|
||||
got
|
||||
}
|
||||
|
||||
fn extend_solidcore(name: &str) -> Got {
|
||||
let (sheet, point, distance, how, _) = extend_case(name);
|
||||
let got = extend_sheet(&sheet.core(), point, TOL, distance, how).map(|b| {
|
||||
let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}"));
|
||||
rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}"));
|
||||
Measured { area: area(&b, 1e-8).unwrap(), counts: counts(&b).unwrap() }
|
||||
});
|
||||
extend_judge(name, "solidcore", got)
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn extend_cpp(name: &str) -> Got {
|
||||
use crate::{Brep, error, ffi};
|
||||
crate::init().unwrap();
|
||||
let (sheet, point, distance, how, _) = extend_case(name);
|
||||
let mode = match how {
|
||||
Continuation::Natural => ffi::trim::EXTEND_NATURAL,
|
||||
Continuation::Tangent => ffi::trim::EXTEND_TANGENT,
|
||||
Continuation::Curvature => ffi::trim::EXTEND_CURVATURE,
|
||||
};
|
||||
let body = Brep::made_core(sheet.core());
|
||||
let r = ffi::trim::extend_sheet(body.cpp().unwrap(), &point, TOL, distance, mode).map_err(|s| match s {
|
||||
ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 },
|
||||
s => error(s),
|
||||
});
|
||||
let got = r.map(|h| {
|
||||
let a = ffi::measure::area(&h, 1e-8).unwrap();
|
||||
let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
|
||||
let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap();
|
||||
rec.validate().unwrap();
|
||||
Measured { area: a, counts: n }
|
||||
});
|
||||
extend_judge(name, "C++", got)
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn extend_parity(name: &str) {
|
||||
match (extend_solidcore(name), extend_cpp(name)) {
|
||||
(Ok(a), Ok(b)) => {
|
||||
assert!(rel(a.area, b.area) < 1e-9, "{name}: solidcore {} vs C++ {}", a.area, b.area);
|
||||
assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)");
|
||||
}
|
||||
(a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"),
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! extend_cases {
|
||||
($($m:ident $(: $ign:meta)?),* $(,)?) => {$(
|
||||
mod $m {
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_closed_form() {
|
||||
let _ = super::extend_solidcore(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
$(#[$ign])?
|
||||
fn solidcore_matches_the_cpp_kernel() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
super::extend_parity(stringify!($m));
|
||||
}
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn the_cpp_kernel_matches_the_closed_form() {
|
||||
let _cpp = crate::route::on_cpp();
|
||||
let _ = super::extend_cpp(stringify!($m));
|
||||
}
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
extend_cases!(
|
||||
extend_a_square,
|
||||
extend_a_square_tangent,
|
||||
extend_a_square_curvature,
|
||||
extend_a_cylinder_along_its_axis,
|
||||
extend_a_cylinder_round_its_axis,
|
||||
extend_a_cylinder_tangent_run_off,
|
||||
extend_a_cylinder_curvature_run_off,
|
||||
extend_picks_near_no_edge,
|
||||
);
|
||||
|
||||
/// The capi's argument checks for an extend, before any kernel call, and a body of more than
|
||||
/// one face.
|
||||
#[test]
|
||||
fn hostile_extend_arguments_are_refused() {
|
||||
let s = Sheet::Square.core();
|
||||
let p = [10.0, 5.0, 0.0];
|
||||
assert_eq!(extend_sheet(&s, [f64::NAN; 3], TOL, 5.0, Continuation::Natural).err(), Some(Error::Argument));
|
||||
for t in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||||
assert_eq!(extend_sheet(&s, p, t, 5.0, Continuation::Natural).err(), Some(Error::Argument), "tolerance {t}");
|
||||
assert_eq!(extend_sheet(&s, p, TOL, t, Continuation::Natural).err(), Some(Error::Argument), "distance {t}");
|
||||
}
|
||||
let cube = make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
assert_eq!(extend_sheet(&cube, p, TOL, 5.0, Continuation::Natural).err(), Some(Error::Unsupported));
|
||||
// A face that is not its surface's whole box: a square with a hole.
|
||||
assert_eq!(extend_sheet(&holed_sheet(), [20.0, 5.0, 0.0], TOL, 5.0, Continuation::Natural).err(), Some(Error::Unsupported));
|
||||
}
|
||||
+32
-95
@@ -50,37 +50,59 @@ use crate::{Brep, Result, error, ffi, init};
|
||||
impl Brep {
|
||||
/// A cylinder about +Z: base centre, radius and height (mm, > 0).
|
||||
pub fn make_cylinder(origin: [f64; 3], radius: f64, height: f64) -> Result<Brep> {
|
||||
if crate::route::backend() == crate::route::Backend::Solidcore {
|
||||
return crate::core::make_cylinder(origin, radius, height).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::make_cylinder(&origin, radius, height).map(Brep).map_err(error)
|
||||
ffi::make_cylinder(&origin, radius, height).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// A sphere: centre and radius (mm, > 0).
|
||||
pub fn make_sphere(center: [f64; 3], radius: f64) -> Result<Brep> {
|
||||
if crate::route::backend() == crate::route::Backend::Solidcore {
|
||||
return crate::core::make_sphere(center, radius).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::make_sphere(¢er, radius).map(Brep).map_err(error)
|
||||
ffi::make_sphere(¢er, radius).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// A cone or frustum about +Z: base centre, base radius (> 0), top radius (≥ 0, not the base
|
||||
/// radius) and height (> 0), in mm.
|
||||
pub fn make_cone(origin: [f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result<Brep> {
|
||||
if crate::route::backend() == crate::route::Backend::Solidcore {
|
||||
return crate::core::make_cone(origin, base_radius, top_radius, height).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::make_cone(&origin, base_radius, top_radius, height).map(Brep).map_err(error)
|
||||
ffi::make_cone(&origin, base_radius, top_radius, height).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// A ring torus about +Z: centre, major radius > minor radius > 0 (mm).
|
||||
pub fn make_torus(center: [f64; 3], major_radius: f64, minor_radius: f64) -> Result<Brep> {
|
||||
if crate::route::backend() == crate::route::Backend::Solidcore {
|
||||
return crate::core::make_torus(center, major_radius, minor_radius).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::make_torus(¢er, major_radius, minor_radius).map(Brep).map_err(error)
|
||||
ffi::make_torus(¢er, major_radius, minor_radius).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// The flat arrays of this body that the mapping reads ([`Brep::export`]), copied out.
|
||||
/// The flat arrays of this body that the mapping reads, from the kernel in force
|
||||
/// ([`crate::route::backend`]).
|
||||
pub fn arrays(&self) -> Result<Arrays> {
|
||||
match crate::route::backend() {
|
||||
crate::route::Backend::Smlib => self.cpp_arrays(),
|
||||
crate::route::Backend::Solidcore => crate::core::to_arrays(self.core()?),
|
||||
}
|
||||
}
|
||||
|
||||
/// The flat arrays of the C++ kernel's body ([`Brep::export`]), every one copied out: the
|
||||
/// mapping reads some, solidcore's import ([`crate::core::from_arrays`]) all of them.
|
||||
pub(crate) fn cpp_arrays(&self) -> Result<Arrays> {
|
||||
let f = self.export()?;
|
||||
let mut out = Arrays::default();
|
||||
for &id in I64_IDS {
|
||||
for id in REGION_SHELL_COUNT..=FACE_DEFINITION_REAL_COUNT {
|
||||
out.ints.insert(id, f.get_i64(id)?);
|
||||
}
|
||||
for &id in F64_IDS {
|
||||
for id in TOLERANCE..=FACE_DEFINITION_REALS {
|
||||
out.reals.insert(id, f.get_f64(id)?);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -150,92 +172,6 @@ fn unsupported(what: impl std::fmt::Display) -> ExportError {
|
||||
|
||||
// ---- The arrays (ids and kinds from `crate::flat`, as in capi/include/smlib.h) ------------
|
||||
|
||||
/// The int64 arrays this mapping reads.
|
||||
const I64_IDS: &[i32] = &[
|
||||
REGION_SHELL_COUNT,
|
||||
REGION_TYPE,
|
||||
SHELL_FACEUSE_COUNT,
|
||||
SHELL_WIRE_EDGE_COUNT,
|
||||
SHELL_POINT_TYPE,
|
||||
FACEUSE_FACE,
|
||||
FACEUSE_ORIENTATION,
|
||||
FACE_LOOP_COUNT,
|
||||
FACE_SURFACE_TYPE,
|
||||
LOOP_EDGEUSE_COUNT,
|
||||
LOOP_VERTEX,
|
||||
EDGEUSE_EDGE,
|
||||
EDGEUSE_ORIENTATION,
|
||||
EDGEUSE_NEXT_RADIAL,
|
||||
EDGEUSE_RADIAL_ENTRY,
|
||||
EDGEUSE_NURBS_VERTEX_COUNT,
|
||||
EDGEUSE_NURBS_ORDER,
|
||||
EDGE_CURVE_TYPE,
|
||||
EDGE_VERTICES,
|
||||
EDGE_NURBS_VERTEX_COUNT,
|
||||
EDGE_NURBS_ORDER,
|
||||
FACE_NURBS_U_VERTEX_COUNT,
|
||||
FACE_NURBS_V_VERTEX_COUNT,
|
||||
FACE_NURBS_U_ORDER,
|
||||
FACE_NURBS_V_ORDER,
|
||||
FACE_NURBS_FORM,
|
||||
EDGE_NURBS_FORM,
|
||||
FACE_DEFINITION_REAL_COUNT,
|
||||
];
|
||||
|
||||
/// The double arrays this mapping reads.
|
||||
const F64_IDS: &[i32] = &[
|
||||
TOLERANCE,
|
||||
EXTENT,
|
||||
FACE_RANGE,
|
||||
EDGE_RANGE,
|
||||
VERTEX_POSITION,
|
||||
EDGE_NURBS_CVS,
|
||||
EDGE_NURBS_KNOTS,
|
||||
EDGE_NURBS_WEIGHTS,
|
||||
EDGE_CIRCLE_CENTER,
|
||||
EDGE_CIRCLE_AXIS,
|
||||
EDGE_CIRCLE_REF_DIRECTION,
|
||||
EDGE_CIRCLE_RADIUS,
|
||||
EDGE_LINE_ORIGIN,
|
||||
EDGE_LINE_DIRECTION,
|
||||
EDGE_ELLIPSE_CENTER,
|
||||
EDGE_ELLIPSE_AXIS,
|
||||
EDGE_ELLIPSE_REF_DIRECTION,
|
||||
EDGE_ELLIPSE_X_RADIUS,
|
||||
EDGE_ELLIPSE_Y_RADIUS,
|
||||
EDGEUSE_NURBS_CVS,
|
||||
EDGEUSE_NURBS_KNOTS,
|
||||
EDGEUSE_NURBS_WEIGHTS,
|
||||
FACE_NURBS_CVS,
|
||||
FACE_NURBS_U_KNOTS,
|
||||
FACE_NURBS_V_KNOTS,
|
||||
FACE_NURBS_WEIGHTS,
|
||||
FACE_SPHERE_CENTER,
|
||||
FACE_SPHERE_AXIS,
|
||||
FACE_SPHERE_REF_DIRECTION,
|
||||
FACE_SPHERE_RADIUS,
|
||||
FACE_PLANE_ORIGIN,
|
||||
FACE_PLANE_AXIS,
|
||||
FACE_PLANE_REF_DIRECTION,
|
||||
FACE_CYLINDER_ORIGIN,
|
||||
FACE_CYLINDER_AXIS,
|
||||
FACE_CYLINDER_REF_DIRECTION,
|
||||
FACE_CYLINDER_RADIUS,
|
||||
FACE_CONE_ORIGIN,
|
||||
FACE_CONE_AXIS,
|
||||
FACE_CONE_REF_DIRECTION,
|
||||
FACE_CONE_RADIUS,
|
||||
FACE_CONE_SEMI_ANGLE,
|
||||
FACE_TORUS_ORIGIN,
|
||||
FACE_TORUS_AXIS,
|
||||
FACE_TORUS_REF_DIRECTION,
|
||||
FACE_TORUS_MAJOR_RADIUS,
|
||||
FACE_TORUS_MINOR_RADIUS,
|
||||
EDGE_TOLERANCE,
|
||||
VERTEX_TOLERANCE,
|
||||
FACE_DEFINITION_REALS,
|
||||
];
|
||||
|
||||
// The kind values as the arrays hold them (int64); a local item shadows the glob import.
|
||||
const SURFACE_NURBS: i64 = flat::SURFACE_NURBS as i64;
|
||||
const SURFACE_PLANE: i64 = flat::SURFACE_PLANE as i64;
|
||||
@@ -384,8 +320,9 @@ fn surfaces(flat: View, n_faces: usize) -> Res<Vec<(b::Surface, Option<b::Surfac
|
||||
v3(flat.f(FACE_CONE_REF_DIRECTION), i, "cone")?,
|
||||
);
|
||||
let (radius, half) = (at(flat.f(FACE_CONE_RADIUS), i, "cone")?, at(flat.f(FACE_CONE_SEMI_ANGLE), i, "cone")?);
|
||||
// SMLib's cone of semi-angle 0 is its cylinder.
|
||||
if half == 0.0 {
|
||||
// SMLib's cone of semi-angle 0 is its cylinder, and one within rounding of it (a thread's
|
||||
// cone of 1e-13 comes out of the trims) is too: the portable record refuses it as a cone.
|
||||
if half.abs() < 1e-12 {
|
||||
b::Surface::Cylinder { frame: f, radius }
|
||||
} else {
|
||||
b::Surface::Cone { frame: f, radius_at_origin: radius, half_angle: half }
|
||||
|
||||
@@ -174,6 +174,16 @@ mod native_tests {
|
||||
assert!((half.unwrap() + 0.5f64.atan()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// A cone whose half angle is within rounding of zero (a52's thread bosses: 1e-13) is exported as
|
||||
/// a cylinder; as a cone the portable record's validation refuses it.
|
||||
#[test]
|
||||
fn a_cone_of_rounding_angle_is_exported_as_a_cylinder() {
|
||||
let near = Brep::make_cone([0.0; 3], 4.0, 4.0 - 1e-12, 10.0).unwrap().to_native().unwrap();
|
||||
assert!(near.faces.iter().any(|f| matches!(f.surface, b::Surface::Cylinder { .. })));
|
||||
assert!(!near.faces.iter().any(|f| matches!(f.surface, b::Surface::Cone { .. })));
|
||||
near.validate().unwrap();
|
||||
}
|
||||
|
||||
/// A hole tangent to the block's outline from inside: the material touches itself along the
|
||||
/// tangent line, where SMLib's one shell uses the edge four times. The record has two copies
|
||||
/// of that edge, each bounding one wedge of material (a face of the side and the half of the
|
||||
|
||||
@@ -39,6 +39,7 @@ pub const FACE_NURBS_V_ORDER: i32 = 31;
|
||||
pub const SOURCE: i32 = 32;
|
||||
pub const FACE_NURBS_FORM: i32 = 33;
|
||||
pub const EDGE_NURBS_FORM: i32 = 34;
|
||||
pub const WIRE_EDGE_NURBS_FORM: i32 = 35;
|
||||
/// Per face whose kind carries a definition (a ball patch), in face order: how many reals of
|
||||
/// `FACE_DEFINITION_REALS` are its definition (a ball patch: 4).
|
||||
pub const FACE_DEFINITION_REAL_COUNT: i32 = 36;
|
||||
@@ -172,18 +173,21 @@ impl Flat {
|
||||
/// The SMLib B-rep these arrays describe (checked on the C side before SMLib sees them).
|
||||
pub fn import(&self) -> Result<Brep> {
|
||||
init()?;
|
||||
ffi::flat::import(&self.0).map(Brep).map_err(error)
|
||||
ffi::flat::import(&self.0).map(Brep::made).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl Brep {
|
||||
/// This body as flat arrays.
|
||||
pub fn export(&self) -> Result<Flat> {
|
||||
ffi::flat::export(&self.0).map(Flat).map_err(error)
|
||||
ffi::flat::export(self.cpp()?).map(Flat).map_err(error)
|
||||
}
|
||||
|
||||
/// Whether SMLib finds this body one closed, manifold solid.
|
||||
pub fn is_manifold_solid(&self) -> Result<bool> {
|
||||
ffi::flat::is_manifold_solid(&self.0).map_err(error)
|
||||
if crate::route::backend() == crate::route::Backend::Solidcore {
|
||||
return crate::core::is_manifold_solid(self.core()?);
|
||||
}
|
||||
ffi::flat::is_manifold_solid(self.cpp()?).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use std::collections::BTreeSet;
|
||||
|
||||
use solidcraft_geom::brep::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, Failed, History, Side};
|
||||
|
||||
use crate::{Brep, Error, Result, error, ffi};
|
||||
use crate::{Brep, Error, Result, core, error, ffi, route};
|
||||
|
||||
/// A result face as SMLib reports it: how it relates to its origins, and the (operand, face)
|
||||
/// pairs it came from, sorted.
|
||||
@@ -67,20 +67,62 @@ impl Brep {
|
||||
/// A copy whose face i and edge i are tagged as face i and edge i of operand `operand` (in
|
||||
/// SMLib's order, which the copy keeps). Run an operation on tagged copies, then read its
|
||||
/// result's [`Brep::history`].
|
||||
///
|
||||
/// The tags are made in the kernel the body is in (solidcore's when it has its body,
|
||||
/// [`crate::core::history`]); converted for the other, the body is tagged there the same way.
|
||||
pub fn tagged(&self, operand: usize) -> Result<Brep> {
|
||||
let operand = i32::try_from(operand).map_err(|_| Error::Argument)?;
|
||||
ffi::history::tag(&self.0, operand).map(Brep).map_err(error)
|
||||
let mut t = match self.core.get() {
|
||||
Some(Ok(b)) => Brep::made_core(core::history::tag(b, operand)?),
|
||||
_ => {
|
||||
let k = i32::try_from(operand).map_err(|_| Error::Argument)?;
|
||||
Brep::made(ffi::history::tag(self.cpp()?, k).map_err(error)?)
|
||||
}
|
||||
};
|
||||
t.operand = Some(operand);
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The history of this body, made by an operation from tagged operands: each face's and
|
||||
/// edge's origins among them. Faces and edges no tag reached have none.
|
||||
/// edge's origins among them. Faces and edges no tag reached have none. Read in the kernel
|
||||
/// that made the body: the tags never cross between them.
|
||||
pub fn history(&self) -> Result<Raw> {
|
||||
let v = ffi::history::read(&self.0).map_err(error)?;
|
||||
if self.maker == route::Backend::Solidcore {
|
||||
return core::history::read(self.core()?);
|
||||
}
|
||||
let v = ffi::history::read(self.cpp()?).map_err(error)?;
|
||||
Raw::decode(&v).ok_or(Error::Exception)
|
||||
}
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
/// This history with its faces in a canonical order (by origins, then change) and each edge
|
||||
/// naming its faces by that order, the edges sorted: two kernels' histories of one result
|
||||
/// compare equal whatever order each lists its faces and edges in.
|
||||
#[cfg(all(test, feature = "native"))]
|
||||
pub(crate) fn canonical(&self) -> Raw {
|
||||
let mut order: Vec<usize> = (0..self.faces.len()).collect();
|
||||
order.sort_by(|a, b| {
|
||||
let (fa, fb) = (&self.faces[*a], &self.faces[*b]);
|
||||
(&fa.origins, fa.change as u8).cmp(&(&fb.origins, fb.change as u8))
|
||||
});
|
||||
let mut at = vec![0; order.len()];
|
||||
for (new, old) in order.iter().enumerate() {
|
||||
at[*old] = new;
|
||||
}
|
||||
let faces = order.iter().map(|i| self.faces[*i].clone()).collect();
|
||||
let mut edges: Vec<RawEdge> = self
|
||||
.edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut f: Vec<usize> = e.faces.iter().map(|i| at[*i]).collect();
|
||||
f.sort();
|
||||
RawEdge { faces: f, origins: e.origins.clone() }
|
||||
})
|
||||
.collect();
|
||||
edges.sort_by(|a, b| (&a.origins, &a.faces).cmp(&(&b.origins, &b.faces)));
|
||||
Raw { faces, edges }
|
||||
}
|
||||
|
||||
/// Decodes `smlib_history_read`'s records; `None` where they don't read as its layout.
|
||||
fn decode(v: &[i64]) -> Option<Raw> {
|
||||
let mut r = Reader(v.iter());
|
||||
|
||||
@@ -666,15 +666,23 @@ mod analytic;
|
||||
mod tests;
|
||||
|
||||
impl crate::Brep {
|
||||
/// A body of the kernel in force ([`crate::route::backend`]), from flat arrays.
|
||||
pub(crate) fn from_arrays(a: &Arrays) -> crate::Result<crate::Brep> {
|
||||
match crate::route::backend() {
|
||||
crate::route::Backend::Smlib => crate::flat::Flat::from_arrays(a)?.import(),
|
||||
crate::route::Backend::Solidcore => crate::core::from_arrays(a).map(crate::Brep::made_core),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMLib B-rep of a schema 4 record. `trusted` as in [`arrays`].
|
||||
pub fn from_record(brep: &b::Brep, trusted: bool) -> crate::Result<crate::Brep> {
|
||||
let a = arrays(brep, trusted).map_err(crate::Error::Record)?;
|
||||
crate::flat::Flat::from_arrays(&a)?.import()
|
||||
crate::Brep::from_arrays(&a)
|
||||
}
|
||||
|
||||
/// [`crate::Brep::from_record`] through [`arrays_keeping_splines`].
|
||||
pub fn from_record_keeping_splines(brep: &b::Brep, trusted: bool) -> crate::Result<crate::Brep> {
|
||||
let a = arrays_keeping_splines(brep, trusted).map_err(crate::Error::Record)?;
|
||||
crate::flat::Flat::from_arrays(&a)?.import()
|
||||
crate::Brep::from_arrays(&a)
|
||||
}
|
||||
}
|
||||
|
||||
+137
-19
@@ -6,16 +6,19 @@
|
||||
//! the handles, turns status codes into [`Error`], and sends SMLib's one-line-per-call log to the
|
||||
//! `log` crate (target `smlib`).
|
||||
//!
|
||||
//! Built without the `native` feature, the crate needs no C++ toolchain and every call returns
|
||||
//! [`Error::NotBuilt`].
|
||||
//! The `native` feature (the apps' `smlib`, off by default) compiles and links the C++ kernel.
|
||||
//! Without it the crate needs no C++ toolchain, no SMLib sources and no prebuilt library: solidcore
|
||||
//! answers every operation, and asking for the C++ kernel (`SOLIDCRAFT_KERNEL=smlib`) makes every
|
||||
//! call that would reach it return [`Error::NotBuilt`], never the other kernel's answer.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::path::Path;
|
||||
use std::sync::Once;
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::{Once, OnceLock};
|
||||
|
||||
mod check;
|
||||
mod core;
|
||||
pub mod export;
|
||||
mod ffi;
|
||||
pub mod flat;
|
||||
@@ -24,6 +27,7 @@ pub mod import;
|
||||
mod measure;
|
||||
mod ops;
|
||||
pub mod pcurves;
|
||||
pub mod route;
|
||||
mod sheets;
|
||||
mod split;
|
||||
pub mod ssi;
|
||||
@@ -75,10 +79,18 @@ pub enum Error {
|
||||
Exception,
|
||||
/// The C API is a different version from the one this crate was written against.
|
||||
Version(i32),
|
||||
/// This build has no SMLib (the `native` feature is off).
|
||||
/// The C++ kernel was asked for on a build without it (the `native` feature, which the apps'
|
||||
/// `smlib` feature turns on, is off): [`NOT_BUILT`].
|
||||
NotBuilt,
|
||||
/// A B-rep record SMLib cannot take (see [`import::Unsupported`]).
|
||||
Record(import::Unsupported),
|
||||
/// solidcore reported an error (the call, then its message).
|
||||
Solidcore(String),
|
||||
/// solidcore panicked; the guard caught it (the call, then the panic's message).
|
||||
Internal(String),
|
||||
/// A step of the C++ kernel solidcore has not ported yet, refused rather than built another
|
||||
/// way: what is missing, and the solidcore issue that ports it.
|
||||
Unported(&'static str),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
@@ -114,8 +126,11 @@ impl std::fmt::Display for Error {
|
||||
}
|
||||
Error::Exception => write!(f, "SMLib raised an internal exception"),
|
||||
Error::Version(v) => write!(f, "SMLib C API version {v}, expected {}", ffi::ABI_VERSION),
|
||||
Error::NotBuilt => write!(f, "not supported yet: this build has no SMLib"),
|
||||
Error::NotBuilt => write!(f, "{NOT_BUILT}"),
|
||||
Error::Record(u) => write!(f, "{u}"),
|
||||
Error::Solidcore(e) => write!(f, "solidcore: {e}"),
|
||||
Error::Internal(e) => write!(f, "solidcore failed internally in {e}"),
|
||||
Error::Unported(what) => write!(f, "not supported yet: {what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +139,14 @@ impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// What [`Error::NotBuilt`] says: the C++ kernel is opt-in at build time, and how to opt in.
|
||||
pub const NOT_BUILT: &str = "this build doesn't include the C++ SMLib kernel; build with --features smlib";
|
||||
|
||||
/// Whether this build links the C++ SMLib kernel (the `native` feature).
|
||||
pub const fn built() -> bool {
|
||||
cfg!(feature = "native")
|
||||
}
|
||||
|
||||
fn error(s: i32) -> Error {
|
||||
match s {
|
||||
ffi::ERR_ARG | ffi::ERR_BUFFER => Error::Argument,
|
||||
@@ -182,6 +205,8 @@ static INIT_RESULT: AtomicI32 = AtomicI32::new(0);
|
||||
/// Checks the C API's version, creates SMLib's context and routes its log to the `log` crate.
|
||||
/// Every other call runs it first; calling it at start-up keeps the cost out of the first operation.
|
||||
pub fn init() -> Result<()> {
|
||||
#[cfg(test)]
|
||||
route::reached_cpp();
|
||||
INIT.call_once(|| {
|
||||
let v = ffi::abi_version();
|
||||
let result = if v == ffi::NOT_BUILT {
|
||||
@@ -201,24 +226,51 @@ pub fn init() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where failing calls leave their inputs for replay (see the C API's `smlib_set_dump_dir`);
|
||||
/// `None` turns that off.
|
||||
/// Where failing calls leave their inputs for replay, on either kernel (see the C API's
|
||||
/// `smlib_set_dump_dir`); `None` turns that off. A build without the C++ kernel has only solidcore's.
|
||||
pub fn set_dump_dir(dir: Option<&Path>) -> Result<()> {
|
||||
init()?;
|
||||
let c = dir.map(c_path).transpose()?;
|
||||
check(ffi::set_dump_dir(c.as_deref()))
|
||||
core::dump::set_dir(dir.map(Path::to_path_buf));
|
||||
// A build or a run on solidcore alone never starts the C++ kernel to tell it.
|
||||
if route::backend() != route::Backend::Smlib {
|
||||
return Ok(());
|
||||
}
|
||||
match init() {
|
||||
Ok(()) => check(ffi::set_dump_dir(c.as_deref())),
|
||||
Err(Error::NotBuilt) => Ok(()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SMLib call running right now, if any: for crash reports. Takes no lock.
|
||||
/// The kernel call running right now, on either kernel, if any: for crash reports. Takes no lock.
|
||||
pub fn current_call() -> Option<String> {
|
||||
if let Some(call) = core::dump::current() {
|
||||
return Some(call);
|
||||
}
|
||||
if route::backend() != route::Backend::Smlib {
|
||||
return None;
|
||||
}
|
||||
let mut buf = [0u8; 512];
|
||||
let n = ffi::current_call(&mut buf);
|
||||
let s = buf.get(..n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
/// An SMLib B-rep. Immutable: operations return new bodies.
|
||||
pub struct Brep(ffi::Handle);
|
||||
/// A B-rep in either kernel ([`route`]). Immutable: operations return new bodies. A body made
|
||||
/// by one kernel is converted for the other the first time an operation there needs it, through the
|
||||
/// flat arrays both understand, and keeps both. A process has one kernel at a time, and this stays
|
||||
/// because that kernel can change under a live body: changing the preference or `diagnostics.kernel`
|
||||
/// leaves bodies already computed as they are, so the next operation on one needs it on the new
|
||||
/// kernel. The parity tests take solidcore's bodies to the C++ kernel the same way.
|
||||
pub struct Brep {
|
||||
cpp: OnceLock<Result<ffi::Handle>>,
|
||||
core: OnceLock<Result<core::Body>>,
|
||||
/// The kernel that made it, whose tags [`Brep::history`] reads (the arrays carry none).
|
||||
maker: route::Backend,
|
||||
/// `Some(k)` for [`Brep::tagged`]`(k)`: its faces and edges stand for their own positions in
|
||||
/// operand `k`, so the body converted for the other kernel is tagged the same way there.
|
||||
operand: Option<usize>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Brep {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -227,26 +279,83 @@ impl std::fmt::Debug for Brep {
|
||||
}
|
||||
|
||||
impl Brep {
|
||||
/// A body the C++ kernel made.
|
||||
fn made(h: ffi::Handle) -> Brep {
|
||||
#[cfg(test)]
|
||||
route::reached_cpp();
|
||||
Brep { cpp: OnceLock::from(Ok(h)), core: OnceLock::new(), maker: route::Backend::Smlib, operand: None }
|
||||
}
|
||||
|
||||
/// A body solidcore made.
|
||||
fn made_core(b: core::Body) -> Brep {
|
||||
Brep { cpp: OnceLock::new(), core: OnceLock::from(Ok(b)), maker: route::Backend::Solidcore, operand: None }
|
||||
}
|
||||
|
||||
/// The C++ kernel's body: converted from solidcore's the first time it is asked for (and
|
||||
/// tagged as solidcore's is, for a [`Brep::tagged`] body).
|
||||
fn cpp(&self) -> Result<&ffi::Handle> {
|
||||
#[cfg(test)]
|
||||
route::reached_cpp();
|
||||
let made = self.cpp.get_or_init(|| {
|
||||
let core = self.core.get().ok_or(Error::Empty)?.as_ref().map_err(Clone::clone)?;
|
||||
let a = core::to_arrays(core)?;
|
||||
let h = flat::Flat::from_arrays(&a)?.import()?.cpp.into_inner().unwrap_or(Err(Error::Empty))?;
|
||||
match self.operand {
|
||||
Some(k) => ffi::history::tag(&h, i32::try_from(k).map_err(|_| Error::Argument)?).map_err(error),
|
||||
None => Ok(h),
|
||||
}
|
||||
});
|
||||
made.as_ref().map_err(Clone::clone)
|
||||
}
|
||||
|
||||
/// solidcore's body: converted from the C++ kernel's the first time it is asked for (and
|
||||
/// tagged as the C++ kernel's is, for a [`Brep::tagged`] body).
|
||||
fn core(&self) -> Result<&core::Body> {
|
||||
let made = self.core.get_or_init(|| {
|
||||
self.cpp.get().ok_or(Error::Empty)?.as_ref().map_err(Clone::clone)?;
|
||||
let b = core::from_arrays(&self.cpp_arrays()?)?;
|
||||
match self.operand {
|
||||
Some(k) => core::history::tag(&b, k),
|
||||
None => Ok(b),
|
||||
}
|
||||
});
|
||||
made.as_ref().map_err(Clone::clone)
|
||||
}
|
||||
|
||||
/// An axis-aligned box from its lower corner, with positive edge lengths (mm).
|
||||
pub fn make_box(origin: [f64; 3], size: [f64; 3]) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::make_box(origin, size).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::make_box(&origin, &size).map(Brep).map_err(error)
|
||||
ffi::make_box(&origin, &size).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// Reads a native SMLib `.smb` file.
|
||||
pub fn read(path: &Path, ascii: bool) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
c_path(path)?;
|
||||
return core::file::read(path, ascii).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::read(&c_path(path)?, ascii).map(Brep).map_err(error)
|
||||
ffi::read(&c_path(path)?, ascii).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// Writes this body to a native SMLib `.smb` file.
|
||||
pub fn write(&self, path: &Path, ascii: bool) -> Result<()> {
|
||||
check(ffi::write(&self.0, &c_path(path)?, ascii))
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
c_path(path)?;
|
||||
return core::file::write(self.core()?, path, ascii);
|
||||
}
|
||||
check(ffi::write(self.cpp()?, &c_path(path)?, ascii))
|
||||
}
|
||||
|
||||
/// A deep copy.
|
||||
pub fn try_clone(&self) -> Result<Brep> {
|
||||
ffi::copy(&self.0).map(Brep).map_err(error)
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::transform::copy(self.core()?).map(Brep::made_core);
|
||||
}
|
||||
ffi::copy(self.cpp()?).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// This body moved by the affine `matrix` (column-major: columns 0–2 the images of x, y and
|
||||
@@ -254,17 +363,26 @@ impl Brep {
|
||||
/// mirror it, a non-uniform scale or a shear turns its geometry into NURBS, tolerances scale
|
||||
/// with it.
|
||||
pub fn transformed(&self, matrix: &[[f64; 4]; 4]) -> Result<Brep> {
|
||||
ffi::transform(&self.0, matrix).map(Brep).map_err(error)
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::transform::transform(self.core()?, matrix).map(Brep::made_core);
|
||||
}
|
||||
ffi::transform(self.cpp()?, matrix).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// The enclosed volume (mm³) at `relative_accuracy` (SMLib clamps it to [1e-8, 1e-1]).
|
||||
pub fn volume(&self, relative_accuracy: f64) -> Result<f64> {
|
||||
ffi::volume(&self.0, relative_accuracy).map_err(error)
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::volume(self.core()?, relative_accuracy);
|
||||
}
|
||||
ffi::volume(self.cpp()?, relative_accuracy).map_err(error)
|
||||
}
|
||||
|
||||
/// Face, edge and vertex counts.
|
||||
pub fn counts(&self) -> Result<[usize; 3]> {
|
||||
let c = ffi::counts(&self.0).map_err(error)?;
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::counts(self.core()?);
|
||||
}
|
||||
let c = ffi::counts(self.cpp()?).map_err(error)?;
|
||||
Ok(c.map(|n| usize::try_from(n).unwrap_or(0)))
|
||||
}
|
||||
}
|
||||
|
||||
+131
-60
@@ -2,7 +2,12 @@
|
||||
//! surfaces rather than a tessellation; the exact box; how far an edge lies from a face's surface;
|
||||
//! where a point lies against a body.
|
||||
|
||||
use crate::{Brep, Result, error, ffi};
|
||||
use crate::{Brep, Result, core, error, ffi, route};
|
||||
|
||||
/// Whether the Measure family runs on solidcore ([`route`]).
|
||||
fn on_core() -> bool {
|
||||
route::backend() == route::Backend::Solidcore
|
||||
}
|
||||
|
||||
/// The accuracy [`Brep::mass_properties`] and [`Brep::area`] use unless told otherwise: the
|
||||
/// finest SMLib allows. Analytic primitives then come out within 2e-7 of their closed forms.
|
||||
@@ -50,7 +55,10 @@ impl Brep {
|
||||
/// else inside a material region or outside. A body with no material (a sheet, an open shell)
|
||||
/// has no inside. A non-finite coordinate is [`Error::Argument`](crate::Error::Argument).
|
||||
pub fn classify_point(&self, p: [f64; 3]) -> Result<PointClass> {
|
||||
match ffi::measure::classify_point(&self.0, p).map_err(error)? {
|
||||
if on_core() {
|
||||
return core::classify_point(self.core()?, p);
|
||||
}
|
||||
match ffi::measure::classify_point(self.cpp()?, p).map_err(error)? {
|
||||
ffi::measure::POINT_OUTSIDE => Ok(PointClass::Outside),
|
||||
ffi::measure::POINT_INSIDE => Ok(PointClass::Inside),
|
||||
ffi::measure::POINT_ON_BOUNDARY => Ok(PointClass::OnBoundary),
|
||||
@@ -64,7 +72,10 @@ impl Brep {
|
||||
/// touching along edges or at points, measure too. A body with no material (a sheet, an open
|
||||
/// shell) has volume 0 and no inertia, its faces' area and the centroid of that area.
|
||||
pub fn mass_properties(&self, relative_accuracy: f64) -> Result<MassProperties> {
|
||||
let m = ffi::measure::mass_properties(&self.0, relative_accuracy).map_err(error)?;
|
||||
if on_core() {
|
||||
return core::mass_properties(self.core()?, relative_accuracy);
|
||||
}
|
||||
let m = ffi::measure::mass_properties(self.cpp()?, relative_accuracy).map_err(error)?;
|
||||
let [area, volume, cx, cy, cz, ixx, iyy, izz, iyz, izx, ixy] = m;
|
||||
Ok(MassProperties { area, volume, centroid: [cx, cy, cz], moments: [ixx, iyy, izz], products: [iyz, izx, ixy] })
|
||||
}
|
||||
@@ -72,7 +83,10 @@ impl Brep {
|
||||
/// The exact axis-aligned box, least corner then greatest: the extrema of every vertex, edge
|
||||
/// and face on its exact curve or surface, not grown by tolerances or control hulls.
|
||||
pub fn bounds(&self) -> Result<([f64; 3], [f64; 3])> {
|
||||
ffi::measure::bounds(&self.0).map_err(error)
|
||||
if on_core() {
|
||||
return core::bounds(self.core()?);
|
||||
}
|
||||
ffi::measure::bounds(self.cpp()?).map_err(error)
|
||||
}
|
||||
|
||||
/// How far edge `edge` (its curve over its interval) lies from the surface of face `face`,
|
||||
@@ -82,13 +96,113 @@ impl Brep {
|
||||
/// goes; SMLib keeps such an edge whole when making its UV trim only when its zone tolerance
|
||||
/// (half the flat arrays' `EDGE_TOLERANCE`) covers this.
|
||||
pub fn edge_face_distance(&self, edge: usize, face: usize) -> Result<f64> {
|
||||
if on_core() {
|
||||
return core::edge_face_distance(self.core()?, edge, face);
|
||||
}
|
||||
let (Ok(e), Ok(f)) = (i64::try_from(edge), i64::try_from(face)) else { return Err(error(ffi::ERR_ARG)) };
|
||||
ffi::measure::edge_face_distance(&self.0, e, f).map_err(error)
|
||||
ffi::measure::edge_face_distance(self.cpp()?, e, f).map_err(error)
|
||||
}
|
||||
|
||||
/// Total area of every face, each counted once, so sheets and open shells too.
|
||||
pub fn area(&self, relative_accuracy: f64) -> Result<f64> {
|
||||
ffi::measure::area(&self.0, relative_accuracy).map_err(error)
|
||||
if on_core() {
|
||||
return core::area(self.core()?, relative_accuracy);
|
||||
}
|
||||
ffi::measure::area(self.cpp()?, relative_accuracy).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed forms of the primitives' measures, for the tests of both kernels.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod closed {
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Area, volume, centroid, Ixx, Iyy, Izz about it (products are 0 for all), and the exact box.
|
||||
pub(crate) struct Closed {
|
||||
pub area: f64,
|
||||
pub volume: f64,
|
||||
pub centroid: [f64; 3],
|
||||
pub moments: [f64; 3],
|
||||
pub bounds: ([f64; 3], [f64; 3]),
|
||||
}
|
||||
|
||||
/// An axis-aligned box from its lower corner.
|
||||
pub(crate) fn cuboid(o: [f64; 3], size: [f64; 3]) -> Closed {
|
||||
let [a, b, c] = size;
|
||||
let v = a * b * c;
|
||||
Closed {
|
||||
area: 2.0 * (a * b + b * c + c * a),
|
||||
volume: v,
|
||||
centroid: [o[0] + a / 2.0, o[1] + b / 2.0, o[2] + c / 2.0],
|
||||
moments: [v * (b * b + c * c) / 12.0, v * (a * a + c * c) / 12.0, v * (a * a + b * b) / 12.0],
|
||||
bounds: (o, [o[0] + a, o[1] + b, o[2] + c]),
|
||||
}
|
||||
}
|
||||
|
||||
/// A cylinder about +Z on its base centre.
|
||||
pub(crate) fn cylinder(o: [f64; 3], r: f64, h: f64) -> Closed {
|
||||
let v = PI * r * r * h;
|
||||
let ixx = v * (3.0 * r * r + h * h) / 12.0;
|
||||
Closed {
|
||||
area: 2.0 * PI * r * r + 2.0 * PI * r * h,
|
||||
volume: v,
|
||||
centroid: [o[0], o[1], o[2] + h / 2.0],
|
||||
moments: [ixx, ixx, v * r * r / 2.0],
|
||||
bounds: ([o[0] - r, o[1] - r, o[2]], [o[0] + r, o[1] + r, o[2] + h]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sphere(o: [f64; 3], r: f64) -> Closed {
|
||||
let v = 4.0 / 3.0 * PI * r * r * r;
|
||||
let i = 0.4 * v * r * r;
|
||||
Closed {
|
||||
area: 4.0 * PI * r * r,
|
||||
volume: v,
|
||||
centroid: o,
|
||||
moments: [i; 3],
|
||||
bounds: ([o[0] - r, o[1] - r, o[2] - r], [o[0] + r, o[1] + r, o[2] + r]),
|
||||
}
|
||||
}
|
||||
|
||||
/// A frustum (a cone when `rt` is 0) about +Z on its base centre. Its volume integrals are
|
||||
/// polynomials in z of degree at most 4, so three-point Gauss-Legendre gives them exactly.
|
||||
pub(crate) fn cone(o: [f64; 3], rb: f64, rt: f64, h: f64) -> Closed {
|
||||
let (x, w) = ([-(0.6f64).sqrt(), 0.0, (0.6f64).sqrt()], [5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0]);
|
||||
let (mut v, mut mz, mut zz, mut izz) = (0.0, 0.0, 0.0, 0.0);
|
||||
for (xi, wi) in x.iter().zip(w) {
|
||||
let (z, wi) = (h / 2.0 * (1.0 + xi), wi * h / 2.0);
|
||||
let rho = rb + (rt - rb) * z / h;
|
||||
let disc = PI * rho * rho;
|
||||
v += wi * disc;
|
||||
mz += wi * disc * z;
|
||||
zz += wi * disc * z * z;
|
||||
izz += wi * disc * rho * rho / 2.0;
|
||||
}
|
||||
let zc = mz / v;
|
||||
let ixx = izz / 2.0 + zz - v * zc * zc;
|
||||
let s = ((rb - rt).powi(2) + h * h).sqrt();
|
||||
let m = rb.max(rt);
|
||||
Closed {
|
||||
area: PI * (rb * rb + rt * rt + (rb + rt) * s),
|
||||
volume: v,
|
||||
centroid: [o[0], o[1], o[2] + zc],
|
||||
moments: [ixx, ixx, izz],
|
||||
bounds: ([o[0] - m, o[1] - m, o[2]], [o[0] + m, o[1] + m, o[2] + h]),
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring torus about +Z (Pappus for area and volume).
|
||||
pub(crate) fn torus(o: [f64; 3], big: f64, r: f64) -> Closed {
|
||||
let v = 2.0 * PI * PI * big * r * r;
|
||||
let ixx = v * (big * big / 2.0 + 5.0 * r * r / 8.0);
|
||||
let m = big + r;
|
||||
Closed {
|
||||
area: 4.0 * PI * PI * big * r,
|
||||
volume: v,
|
||||
centroid: o,
|
||||
moments: [ixx, ixx, v * (big * big + 0.75 * r * r)],
|
||||
bounds: ([o[0] - m, o[1] - m, o[2] - r], [o[0] + m, o[1] + m, o[2] + r]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +210,6 @@ impl Brep {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Error;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
if b == 0.0 { a.abs() } else { (a / b - 1.0).abs() }
|
||||
}
|
||||
@@ -152,7 +264,9 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// Closed forms: area, volume, centroid and Ixx, Iyy, Izz about it (products are 0 for all).
|
||||
use closed::Closed;
|
||||
|
||||
/// A primitive the C++ kernel made, with its closed forms.
|
||||
struct Expected {
|
||||
name: &'static str,
|
||||
body: Brep,
|
||||
@@ -163,67 +277,24 @@ mod tests {
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)] // test fixture
|
||||
fn expected(name: &'static str, body: Result<Brep>, c: Closed) -> Expected {
|
||||
Expected { name, body: body.unwrap(), area: c.area, volume: c.volume, centroid: c.centroid, moments: c.moments }
|
||||
}
|
||||
|
||||
fn cylinder(name: &'static str, o: [f64; 3], r: f64, h: f64) -> Expected {
|
||||
let v = PI * r * r * h;
|
||||
let ixx = v * (3.0 * r * r + h * h) / 12.0;
|
||||
Expected {
|
||||
name,
|
||||
body: Brep::make_cylinder(o, r, h).unwrap(),
|
||||
area: 2.0 * PI * r * r + 2.0 * PI * r * h,
|
||||
volume: v,
|
||||
centroid: [o[0], o[1], o[2] + h / 2.0],
|
||||
moments: [ixx, ixx, v * r * r / 2.0],
|
||||
}
|
||||
expected(name, Brep::make_cylinder(o, r, h), closed::cylinder(o, r, h))
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)] // test fixture
|
||||
fn sphere(name: &'static str, o: [f64; 3], r: f64) -> Expected {
|
||||
let v = 4.0 / 3.0 * PI * r * r * r;
|
||||
let i = 0.4 * v * r * r;
|
||||
Expected { name, body: Brep::make_sphere(o, r).unwrap(), area: 4.0 * PI * r * r, volume: v, centroid: o, moments: [i; 3] }
|
||||
expected(name, Brep::make_sphere(o, r), closed::sphere(o, r))
|
||||
}
|
||||
|
||||
/// A frustum (a cone when `rt` is 0) about +Z on its base centre. Its volume integrals are
|
||||
/// polynomials in z of degree at most 4, so three-point Gauss-Legendre gives them exactly.
|
||||
#[allow(clippy::unwrap_used)] // test fixture
|
||||
fn cone(name: &'static str, o: [f64; 3], rb: f64, rt: f64, h: f64) -> Expected {
|
||||
let (x, w) = ([-(0.6f64).sqrt(), 0.0, (0.6f64).sqrt()], [5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0]);
|
||||
let (mut v, mut mz, mut zz, mut izz) = (0.0, 0.0, 0.0, 0.0);
|
||||
for i in 0..3 {
|
||||
let (z, wi) = (h / 2.0 * (1.0 + x[i]), w[i] * h / 2.0);
|
||||
let rho = rb + (rt - rb) * z / h;
|
||||
let disc = PI * rho * rho;
|
||||
v += wi * disc;
|
||||
mz += wi * disc * z;
|
||||
zz += wi * disc * z * z;
|
||||
izz += wi * disc * rho * rho / 2.0;
|
||||
}
|
||||
let zc = mz / v;
|
||||
let ixx = izz / 2.0 + zz - v * zc * zc;
|
||||
let s = ((rb - rt).powi(2) + h * h).sqrt();
|
||||
Expected {
|
||||
name,
|
||||
body: Brep::make_cone(o, rb, rt, h).unwrap(),
|
||||
area: PI * (rb * rb + rt * rt + (rb + rt) * s),
|
||||
volume: v,
|
||||
centroid: [o[0], o[1], o[2] + zc],
|
||||
moments: [ixx, ixx, izz],
|
||||
}
|
||||
expected(name, Brep::make_cone(o, rb, rt, h), closed::cone(o, rb, rt, h))
|
||||
}
|
||||
|
||||
/// A ring torus about +Z (Pappus for area and volume).
|
||||
#[allow(clippy::unwrap_used)] // test fixture
|
||||
fn torus(name: &'static str, o: [f64; 3], big: f64, r: f64) -> Expected {
|
||||
let v = 2.0 * PI * PI * big * r * r;
|
||||
let ixx = v * (big * big / 2.0 + 5.0 * r * r / 8.0);
|
||||
Expected {
|
||||
name,
|
||||
body: Brep::make_torus(o, big, r).unwrap(),
|
||||
area: 4.0 * PI * PI * big * r,
|
||||
volume: v,
|
||||
centroid: o,
|
||||
moments: [ixx, ixx, v * (big * big + 0.75 * r * r)],
|
||||
}
|
||||
expected(name, Brep::make_torus(o, big, r), closed::torus(o, big, r))
|
||||
}
|
||||
|
||||
/// Every primitive against its closed form, at the origin and 2e5 mm out, to 1e-6 (tests/oracle
|
||||
|
||||
+112
-30
@@ -6,7 +6,7 @@
|
||||
//! edge (face) nearest to it, which must lie within `tolerance` of it and be nearer than any other
|
||||
//! by more than the body's modelling tolerance ([`Error::NoMatch`], [`Error::Ambiguous`]).
|
||||
|
||||
use crate::{Brep, Error, Result, error, ffi, init};
|
||||
use crate::{Brep, Error, Result, core, error, ffi, init, route};
|
||||
|
||||
/// What size builds a blend SMLib refused ([`blend_fit`]): from the refusal's own cause, never by
|
||||
/// building again.
|
||||
@@ -27,6 +27,11 @@ pub enum Fit {
|
||||
/// ([`Error::NoRoom`]) or whose setback it refused as too short; `None` where it names no single
|
||||
/// size. Read it right after the refused call: SMLib keeps only the last one.
|
||||
pub fn blend_fit() -> Option<Fit> {
|
||||
// solidcore names no size that builds yet: its refusals keep their own words, and the C++
|
||||
// kernel's last refusal is never read for one of solidcore's.
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return None;
|
||||
}
|
||||
let (size, what) = ffi::ops::blend_fit_size()?;
|
||||
match what {
|
||||
ffi::ops::FIT_SIZE => Some(Fit::Size(size)),
|
||||
@@ -76,7 +81,7 @@ pub const MAX_UNION_BREPS: usize = 100_000;
|
||||
/// Most points one selection may hold (the C API's limit).
|
||||
pub const MAX_POINTS: usize = 4096;
|
||||
|
||||
fn pick_error((s, at): ffi::ops::PickError) -> Error {
|
||||
pub(crate) fn pick_error((s, at): ffi::ops::PickError) -> Error {
|
||||
match (s, at) {
|
||||
(ffi::ops::ERR_PICK_NONE, Some(point)) => Error::NoMatch { point },
|
||||
(ffi::ops::ERR_PICK_AMBIGUOUS, Some(point)) => Error::Ambiguous { point },
|
||||
@@ -84,7 +89,7 @@ fn pick_error((s, at): ffi::ops::PickError) -> Error {
|
||||
}
|
||||
}
|
||||
|
||||
fn setback_error((s, at, corner): ffi::ops::SetbackError) -> Error {
|
||||
pub(crate) fn setback_error((s, at, corner): ffi::ops::SetbackError) -> Error {
|
||||
match corner {
|
||||
Some(corner) => Error::CornerRefused { corner },
|
||||
None => pick_error((s, at)),
|
||||
@@ -131,14 +136,17 @@ impl Brep {
|
||||
/// as one body, counted by [`Brep::solid_count`]. Tagged operands ([`Brep::tagged`]) give a
|
||||
/// result whose [`Brep::history`] says where each face and edge came from.
|
||||
pub fn boolean(&self, other: &Brep, op: BooleanOp) -> Result<Option<Brep>> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::boolean::boolean(self.core()?, other.core()?, op).map(|r| r.map(Brep::made_core));
|
||||
}
|
||||
init()?;
|
||||
let op = match op {
|
||||
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
|
||||
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
|
||||
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
|
||||
};
|
||||
match ffi::ops::boolean(&self.0, &other.0, op) {
|
||||
Ok(h) => Ok(Some(Brep(h))),
|
||||
match ffi::ops::boolean(self.cpp()?, other.cpp()?, op) {
|
||||
Ok(h) => Ok(Some(Brep::made(h))),
|
||||
Err(ffi::ERR_EMPTY) => Ok(None),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
@@ -149,26 +157,36 @@ impl Brep {
|
||||
/// tagged each with its own number ([`Brep::tagged`]) give a result whose [`Brep::history`]
|
||||
/// names them. At least one body and at most [`MAX_UNION_BREPS`].
|
||||
pub fn union_all(breps: &[&Brep]) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
if breps.is_empty() || breps.len() > MAX_UNION_BREPS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
let bodies = breps.iter().map(|b| b.core()).collect::<Result<Vec<&core::Body>>>()?;
|
||||
return core::boolean::union_all(&bodies).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
if breps.is_empty() || breps.len() > MAX_UNION_BREPS {
|
||||
return Err(error(ffi::ERR_ARG));
|
||||
}
|
||||
let handles: Vec<&ffi::Handle> = breps.iter().map(|b| &b.0).collect();
|
||||
ffi::ops::boolean_union_all(&handles).map(Brep).map_err(error)
|
||||
let handles = breps.iter().map(|b| b.cpp()).collect::<Result<Vec<&ffi::Handle>>>()?;
|
||||
ffi::ops::boolean_union_all(&handles).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// `self` and `other`, sheets of planar faces on one plane ([`Brep::planar_faces`]), combined
|
||||
/// as regions of that plane by `op`: `None` when the result is empty. The result's edges are
|
||||
/// the inputs' own curves, split where they cross.
|
||||
pub fn boolean2d(&self, other: &Brep, op: BooleanOp) -> Result<Option<Brep>> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::boolean::boolean2d(self.core()?, other.core()?, op).map(|r| r.map(Brep::made_core));
|
||||
}
|
||||
init()?;
|
||||
let op = match op {
|
||||
BooleanOp::Union => ffi::ops::BOOLEAN_UNION,
|
||||
BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT,
|
||||
BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT,
|
||||
};
|
||||
match ffi::ops::boolean2d(&self.0, &other.0, op) {
|
||||
Ok(h) => Ok(Some(Brep(h))),
|
||||
match ffi::ops::boolean2d(self.cpp()?, other.cpp()?, op) {
|
||||
Ok(h) => Ok(Some(Brep::made(h))),
|
||||
Err(ffi::ERR_EMPTY) => Ok(None),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
@@ -178,9 +196,12 @@ impl Brep {
|
||||
/// one body: `None` when the tool meets no face. Tagged operands give the pieces' history
|
||||
/// (each piece is a split of the face it was cut from).
|
||||
pub fn imprint(&self, tool: &Brep) -> Result<Option<Brep>> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::boolean::imprint(self.core()?, tool.core()?).map(|r| r.map(Brep::made_core));
|
||||
}
|
||||
init()?;
|
||||
match ffi::ops::imprint(&self.0, &tool.0) {
|
||||
Ok(h) => Ok(Some(Brep(h))),
|
||||
match ffi::ops::imprint(self.cpp()?, tool.cpp()?) {
|
||||
Ok(h) => Ok(Some(Brep::made(h))),
|
||||
Err(ffi::ops::ERR_NO_CUT) => Ok(None),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
@@ -188,12 +209,18 @@ impl Brep {
|
||||
|
||||
/// The number of separate solid regions (voids not counted).
|
||||
pub fn solid_count(&self) -> Result<usize> {
|
||||
ffi::ops::solid_count(&self.0).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error)
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return crate::core::solid_count(self.core()?);
|
||||
}
|
||||
ffi::ops::solid_count(self.cpp()?).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error)
|
||||
}
|
||||
|
||||
/// The edges nearest to `edges` (each within `tolerance`), exactly those, rounded
|
||||
/// at `radius`.
|
||||
pub fn fillet(&self, edges: &[[f64; 3]], tolerance: f64, radius: f64) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return self.core_blend(Blend::Round, edges, &[radius], tolerance, &[], &[], core::blend::Corners::Filled);
|
||||
}
|
||||
self.pick_op(ffi::ops::Pick::Fillet, edges, tolerance, radius, false)
|
||||
}
|
||||
|
||||
@@ -201,40 +228,49 @@ impl Brep {
|
||||
/// an equal-distance chamfer `distance` back along each face (measured at each edge's
|
||||
/// midpoint), with bevelled corners.
|
||||
pub fn chamfer(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return self.core_blend(Blend::Chamfer, edges, &[distance], tolerance, &[], &[], core::blend::Corners::Filled);
|
||||
}
|
||||
self.pick_op(ffi::ops::Pick::Chamfer, edges, tolerance, distance, false)
|
||||
}
|
||||
|
||||
/// The edges nearest to `edges` (each within `tolerance`), exactly those, blended
|
||||
/// as `kind`, edge i at `sizes[i]`.
|
||||
pub fn blend(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<Brep> {
|
||||
init()?;
|
||||
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return self.core_blend(kind, edges, sizes, tolerance, &[], &[], core::blend::Corners::Filled);
|
||||
}
|
||||
init()?;
|
||||
let kind = match kind {
|
||||
Blend::Round => ffi::ops::BLEND_ROUND,
|
||||
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
|
||||
Blend::Chord => ffi::ops::BLEND_CHORD,
|
||||
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
|
||||
};
|
||||
ffi::ops::blend(kind, &self.0, edges, sizes, tolerance).map(Brep).map_err(pick_error)
|
||||
ffi::ops::blend(kind, self.cpp()?, edges, sizes, tolerance).map(Brep::made).map_err(pick_error)
|
||||
}
|
||||
|
||||
/// [`Brep::blend`], with whether each point picked an edge that a larger round, overflowing
|
||||
/// its faces, removed whole: that edge is gone and not rounded (SMLib rounds the edges whose
|
||||
/// blends overflow first, then what is left of the others).
|
||||
pub fn blend_dropping(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Brep, Vec<bool>)> {
|
||||
init()?;
|
||||
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::blend::blend_dropping(self.core()?, kind, edges, sizes, tolerance).map(|(b, d)| (Brep::made_core(b), d));
|
||||
}
|
||||
init()?;
|
||||
let kind = match kind {
|
||||
Blend::Round => ffi::ops::BLEND_ROUND,
|
||||
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
|
||||
Blend::Chord => ffi::ops::BLEND_CHORD,
|
||||
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
|
||||
};
|
||||
ffi::ops::blend_dropping(kind, &self.0, edges, sizes, tolerance).map(|(b, d)| (Brep(b), d)).map_err(pick_error)
|
||||
ffi::ops::blend_dropping(kind, self.cpp()?, edges, sizes, tolerance).map(|(b, d)| (Brep::made(b), d)).map_err(pick_error)
|
||||
}
|
||||
|
||||
/// [`Brep::blend`] with corner setbacks: each of `corners` picks the vertex nearest it
|
||||
@@ -251,7 +287,6 @@ impl Brep {
|
||||
setbacks: &[f64],
|
||||
tolerance: f64,
|
||||
) -> Result<Brep> {
|
||||
init()?;
|
||||
if edges.len() > MAX_POINTS
|
||||
|| edges.is_empty()
|
||||
|| sizes.len() != edges.len()
|
||||
@@ -261,13 +296,17 @@ impl Brep {
|
||||
{
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return self.core_blend(kind, edges, sizes, tolerance, corners, setbacks, core::blend::Corners::Filled);
|
||||
}
|
||||
init()?;
|
||||
let kind = match kind {
|
||||
Blend::Round => ffi::ops::BLEND_ROUND,
|
||||
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
|
||||
Blend::Chord => ffi::ops::BLEND_CHORD,
|
||||
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
|
||||
};
|
||||
ffi::ops::blend_setback(kind, &self.0, edges, sizes, corners, setbacks, tolerance).map(Brep).map_err(setback_error)
|
||||
ffi::ops::blend_setback(kind, self.cpp()?, edges, sizes, corners, setbacks, tolerance).map(Brep::made).map_err(setback_error)
|
||||
}
|
||||
|
||||
/// [`Brep::blend_setback`] with each set-back corner left open: SMLib's patch between the
|
||||
@@ -284,7 +323,6 @@ impl Brep {
|
||||
setbacks: &[f64],
|
||||
tolerance: f64,
|
||||
) -> Result<(Brep, Vec<Hole>)> {
|
||||
init()?;
|
||||
if edges.len() > MAX_POINTS
|
||||
|| edges.is_empty()
|
||||
|| sizes.len() != edges.len()
|
||||
@@ -294,16 +332,21 @@ impl Brep {
|
||||
{
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::blend::blend_holes(self.core()?, kind, edges, sizes, tolerance, corners, setbacks, core::blend::Corners::Open)
|
||||
.map(|(b, holes)| (Brep::made_core(b), holes));
|
||||
}
|
||||
init()?;
|
||||
let kind = match kind {
|
||||
Blend::Round => ffi::ops::BLEND_ROUND,
|
||||
Blend::Curvature => ffi::ops::BLEND_CURVATURE,
|
||||
Blend::Chord => ffi::ops::BLEND_CHORD,
|
||||
Blend::Chamfer => ffi::ops::BLEND_CHAMFER,
|
||||
};
|
||||
let (h, values) = ffi::ops::blend_setback_open(kind, &self.0, edges, sizes, corners, setbacks, tolerance).map_err(setback_error)?;
|
||||
let (h, values) = ffi::ops::blend_setback_open(kind, self.cpp()?, edges, sizes, corners, setbacks, tolerance).map_err(setback_error)?;
|
||||
// A buffer that does not read as the C API documents it is a buffer error.
|
||||
let holes = holes(&values, corners.len()).ok_or_else(|| error(ffi::ERR_BUFFER))?;
|
||||
Ok((Brep(h), holes))
|
||||
Ok((Brep::made(h), holes))
|
||||
}
|
||||
|
||||
/// The one edge nearest to `edge` (within `tolerance`) rounded with a radius
|
||||
@@ -311,9 +354,12 @@ impl Brep {
|
||||
/// `radii` over the position along the edge, 0 at its end nearest `start` and 1 at the other
|
||||
/// (the edge curve's parameter scaled to 0..1).
|
||||
pub fn fillet_variable(&self, edge: [f64; 3], tolerance: f64, start: [f64; 3], degree: usize, knots: &[f64], radii: &[f64]) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::blend::fillet_variable(self.core()?, edge, tolerance, start, degree, knots, radii).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let degree = i32::try_from(degree).map_err(|_| Error::Argument)?;
|
||||
ffi::ops::fillet_variable(&self.0, &edge, tolerance, &start, degree, knots, radii).map(Brep).map_err(|s| match s {
|
||||
ffi::ops::fillet_variable(self.cpp()?, &edge, tolerance, &start, degree, knots, radii).map(Brep::made).map_err(|s| match s {
|
||||
ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 },
|
||||
ffi::ops::ERR_PICK_AMBIGUOUS => Error::Ambiguous { point: 0 },
|
||||
s => error(s),
|
||||
@@ -325,15 +371,18 @@ impl Brep {
|
||||
/// and by `second` on the other, with bevelled corners (setbacks measured at each edge's
|
||||
/// midpoint).
|
||||
pub fn chamfer_sides(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64, second: ChamferSecond, first: [f64; 3]) -> Result<Brep> {
|
||||
init()?;
|
||||
if edges.is_empty() || edges.len() > MAX_POINTS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::blend::chamfer_sides(self.core()?, edges, tolerance, distance, second, first).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let (value, angle) = match second {
|
||||
ChamferSecond::Distance(d) => (d, false),
|
||||
ChamferSecond::Angle(a) => (a, true),
|
||||
};
|
||||
ffi::ops::chamfer_sides(&self.0, edges, tolerance, distance, value, angle, &first).map(Brep).map_err(pick_error)
|
||||
ffi::ops::chamfer_sides(self.cpp()?, edges, tolerance, distance, value, angle, &first).map(Brep::made).map_err(pick_error)
|
||||
}
|
||||
|
||||
/// Hollowed to a wall of `thickness` (negative: inside the original surface, positive:
|
||||
@@ -342,6 +391,9 @@ impl Brep {
|
||||
/// whose walls would just meet (a hole exactly twice the wall from a side) is
|
||||
/// [`Error::WallsTouch`].
|
||||
pub fn shell(&self, open_faces: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::offset::shell(self.core()?, open_faces, tolerance, thickness).map(Brep::made_core);
|
||||
}
|
||||
self.pick_op(ffi::ops::Pick::Shell, open_faces, tolerance, thickness, true)
|
||||
}
|
||||
|
||||
@@ -350,6 +402,9 @@ impl Brep {
|
||||
/// faces around them extended or trimmed to meet them. Faces moved in past all of the body
|
||||
/// behind them are [`Error::NoRoom`].
|
||||
pub fn offset_faces(&self, faces: &[[f64; 3]], tolerance: f64, distance: f64) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::offset::offset_faces(self.core()?, faces, tolerance, distance).map(Brep::made_core);
|
||||
}
|
||||
self.pick_op(ffi::ops::Pick::OffsetFaces, faces, tolerance, distance, false)
|
||||
}
|
||||
|
||||
@@ -357,8 +412,11 @@ impl Brep {
|
||||
/// against them), or by half each way when `symmetric`, with ruled walls along its open
|
||||
/// edges. A closed solid is [`Error::Argument`].
|
||||
pub fn thicken(&self, thickness: f64, symmetric: bool) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::offset::thicken(self.core()?, thickness, symmetric).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::ops::thicken(&self.0, thickness, symmetric).map(Brep).map_err(error)
|
||||
ffi::ops::thicken(self.cpp()?, thickness, symmetric).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// This body with its topological edges and vertices removed (SMLib's
|
||||
@@ -367,11 +425,14 @@ impl Brep {
|
||||
/// The edges nearest to `keep` (each within `tolerance`) stay. The shape is unchanged, and
|
||||
/// SMLib's history tags follow the merge.
|
||||
pub fn merge_faces(&self, keep: &[[f64; 3]], tolerance: f64) -> Result<Brep> {
|
||||
init()?;
|
||||
if keep.len() > MAX_POINTS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
ffi::ops::merge_faces(&self.0, keep, tolerance).map(Brep).map_err(pick_error)
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::merge::merge_faces(self.core()?, keep, tolerance).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::ops::merge_faces(self.cpp()?, keep, tolerance).map(Brep::made).map_err(pick_error)
|
||||
}
|
||||
|
||||
/// The flat faces nearest to `faces` (each within `tolerance`) tilted by `angle` radians
|
||||
@@ -388,11 +449,32 @@ impl Brep {
|
||||
pull: [f64; 3],
|
||||
angle: f64,
|
||||
) -> Result<Brep> {
|
||||
init()?;
|
||||
if faces.is_empty() || faces.len() > MAX_POINTS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
ffi::ops::draft(&self.0, faces, tolerance, &neutral_origin, &neutral_normal, &pull, angle).map(Brep).map_err(pick_error)
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::offset::draft(self.core()?, faces, tolerance, neutral_origin, neutral_normal, pull, angle).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::ops::draft(self.cpp()?, faces, tolerance, &neutral_origin, &neutral_normal, &pull, angle).map(Brep::made).map_err(pick_error)
|
||||
}
|
||||
|
||||
/// [`Brep::blend`] and its kin on solidcore (`core::blend::blend`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn core_blend(
|
||||
&self,
|
||||
kind: Blend,
|
||||
edges: &[[f64; 3]],
|
||||
sizes: &[f64],
|
||||
tolerance: f64,
|
||||
corners: &[[f64; 3]],
|
||||
setbacks: &[f64],
|
||||
open: core::blend::Corners,
|
||||
) -> Result<Brep> {
|
||||
if edges.len() > MAX_POINTS || edges.is_empty() {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
core::blend::blend(self.core()?, kind, edges, sizes, tolerance, corners, setbacks, open).map(Brep::made_core)
|
||||
}
|
||||
|
||||
fn pick_op(&self, kind: ffi::ops::Pick, points: &[[f64; 3]], tolerance: f64, size: f64, allow_none: bool) -> Result<Brep> {
|
||||
@@ -400,7 +482,7 @@ impl Brep {
|
||||
if points.len() > MAX_POINTS || (points.is_empty() && !allow_none) {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
ffi::ops::pick_op(kind, &self.0, points, tolerance, size).map(Brep).map_err(pick_error)
|
||||
ffi::ops::pick_op(kind, self.cpp()?, points, tolerance, size).map(Brep::made).map_err(pick_error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +402,8 @@ fn a_setback_corner_left_open_returns_its_hole() {
|
||||
let (open, holes) = b.blend_setback_open(Blend::Round, &edges, &[5.0; 3], &[[60.0, 40.0, 20.0]], &[8.0], 0.1).unwrap();
|
||||
// The call is a profiler span like every other, timed by the C API.
|
||||
let report = mark.report();
|
||||
assert!(report.rows.iter().any(|r| r.category == "smlib" && r.name == "smlib_blend_setback_open"), "{report:?}");
|
||||
let on_cpp = crate::route::backend() == crate::route::Backend::Smlib;
|
||||
assert!(!on_cpp || report.rows.iter().any(|r| r.category == "smlib" && r.name == "smlib_blend_setback_open"), "{report:?}");
|
||||
shot(&open, "setback_open");
|
||||
assert_eq!(open.counts().unwrap(), [9, 30, 22]);
|
||||
assert!(!open.is_manifold_solid().unwrap());
|
||||
|
||||
@@ -22,7 +22,7 @@ impl Trims for SmlibTrims {
|
||||
fn trims(face: &TrimFace<'_>) -> Result<Vec<Vec<Curve2>>, String> {
|
||||
let (record, bare) = sheet(face)?;
|
||||
let mut arrays = crate::import::arrays_with_bare_uses(&record, &bare).map_err(|e| e.to_string())?;
|
||||
let import = |a: &crate::import::Arrays| crate::flat::Flat::from_arrays(a).and_then(|f| f.import()).map_err(|e| e.to_string());
|
||||
let import = |a: &crate::import::Arrays| crate::Brep::from_arrays(a).map_err(|e| e.to_string());
|
||||
let mut body = import(&arrays)?;
|
||||
if cover_gaps(&body, face, &bare, &mut arrays)? {
|
||||
body = import(&arrays)?;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Which kernel this process runs on: solidcore, the Rust port of SMLib, unless the C++ SMLib
|
||||
//! fork is asked for. One kernel answers every operation.
|
||||
//!
|
||||
//! The C++ kernel is an opt-in: for a test, a rig or a user who wants the old behaviour. A call that
|
||||
//! fails on solidcore fails, and a path solidcore has no answer for says it is not supported: there is
|
||||
//! no fallback to the other kernel at run time, which would hide its bugs. Asking for the C++ kernel on
|
||||
//! a build without it (the default, wasm included) stays on it too: every call answers
|
||||
//! [`crate::Error::NotBuilt`], which says to build with `--features smlib`.
|
||||
//!
|
||||
//! `SOLIDCRAFT_KERNEL=smlib|solidcore` chooses for the whole process (the rig, the test matrix);
|
||||
//! otherwise the preference ([`set_choice`]) does, and it defaults to solidcore.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// A kernel the process runs on.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
/// The C++ SMLib fork (tangency-labs/smlib) behind its C API.
|
||||
Smlib,
|
||||
/// solidcore (tangency-labs/solidcore), the Rust port of SMLib.
|
||||
Solidcore,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// The id `SOLIDCRAFT_KERNEL`, preferences and reports spell it with.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Smlib => "smlib",
|
||||
Backend::Solidcore => "solidcore",
|
||||
}
|
||||
}
|
||||
|
||||
/// Back from an id; anything else is `None`.
|
||||
pub fn from_id(id: &str) -> Option<Backend> {
|
||||
[Backend::Smlib, Backend::Solidcore].into_iter().find(|b| b.id() == id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: this thread's operations run on the C++ kernel whatever the choice.
|
||||
static ON_CPP: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: how many times this thread has touched a C++ body, and called solidcore.
|
||||
static REACHED: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Tests: this thread has touched a C++ body.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reached_cpp() {
|
||||
REACHED.with(|r| r.set((r.get().0 + 1, r.get().1)));
|
||||
}
|
||||
|
||||
/// Tests: this thread has called solidcore.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reached_core() {
|
||||
REACHED.with(|r| r.set((r.get().0, r.get().1 + 1)));
|
||||
}
|
||||
|
||||
/// Tests: how many times this thread has touched a C++ body and called solidcore since the last
|
||||
/// time this was asked.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_reached() -> (u64, u64) {
|
||||
REACHED.with(|r| r.replace((0, 0)))
|
||||
}
|
||||
|
||||
/// Tests that exercise the C++ kernel opt in: until the guard drops, this thread's operations run
|
||||
/// on it. Thread by thread, so that the other tests in the binary stay on the process's kernel. The
|
||||
/// guard fails a test that never touched a C++ body, so a parity test can't quietly compare
|
||||
/// solidcore with itself.
|
||||
#[cfg(test)]
|
||||
#[cfg_attr(not(feature = "native"), allow(dead_code))]
|
||||
pub(crate) fn on_cpp() -> impl Drop {
|
||||
struct Guard;
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
ON_CPP.with(|c| c.set(false));
|
||||
let (cpp, core) = REACHED.with(std::cell::Cell::get);
|
||||
if std::env::var_os("SOLIDCRAFT_TEST_REACHED").is_some() {
|
||||
eprintln!("REACHED {} cpp={cpp} core={core}", std::thread::current().name().unwrap_or("?"));
|
||||
}
|
||||
assert!(cpp > 0 || std::thread::panicking(), "this test opted in to the C++ kernel and never reached it");
|
||||
}
|
||||
}
|
||||
ON_CPP.with(|c| c.set(true));
|
||||
REACHED.with(|r| r.set((0, 0)));
|
||||
Guard
|
||||
}
|
||||
|
||||
/// The kernel that answers now: `SOLIDCRAFT_KERNEL`, else the preference, else solidcore.
|
||||
pub fn backend() -> Backend {
|
||||
#[cfg(test)]
|
||||
if ON_CPP.with(std::cell::Cell::get) {
|
||||
return Backend::Smlib;
|
||||
}
|
||||
choice()
|
||||
}
|
||||
|
||||
/// `SOLIDCRAFT_KERNEL`, read once. An unknown value is no override (and says so in the log).
|
||||
fn environment() -> Option<Backend> {
|
||||
static ENV: OnceLock<Option<Backend>> = OnceLock::new();
|
||||
*ENV.get_or_init(|| {
|
||||
let v = std::env::var("SOLIDCRAFT_KERNEL").ok()?;
|
||||
let c = Backend::from_id(&v);
|
||||
if c.is_none() {
|
||||
log::warn!(target: "smlib", "SOLIDCRAFT_KERNEL={v}: not smlib or solidcore; ignored");
|
||||
}
|
||||
if c == Some(Backend::Smlib) && !crate::built() {
|
||||
log::warn!(target: "smlib", "SOLIDCRAFT_KERNEL=smlib: {}; every operation will be refused", crate::NOT_BUILT);
|
||||
}
|
||||
c
|
||||
})
|
||||
}
|
||||
|
||||
/// The preference: false for solidcore, true for the C++ kernel.
|
||||
static PREFERS_CPP: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The choice in force: `SOLIDCRAFT_KERNEL` when it is set, else the preference.
|
||||
pub fn choice() -> Backend {
|
||||
environment().unwrap_or_else(|| if PREFERS_CPP.load(Ordering::Relaxed) { Backend::Smlib } else { Backend::Solidcore })
|
||||
}
|
||||
|
||||
/// Sets the preference. `SOLIDCRAFT_KERNEL`, when set, still wins.
|
||||
pub fn set_choice(b: Backend) {
|
||||
PREFERS_CPP.store(b == Backend::Smlib, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether `SOLIDCRAFT_KERNEL` overrides the preference in this process.
|
||||
pub fn overridden() -> bool {
|
||||
environment().is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two ids round trip; `routed`, the old default, is not one of them.
|
||||
#[test]
|
||||
fn ids_round_trip() {
|
||||
for b in [Backend::Smlib, Backend::Solidcore] {
|
||||
assert_eq!(Backend::from_id(b.id()), Some(b));
|
||||
}
|
||||
for other in ["routed", "truck", "SMLIB", ""] {
|
||||
assert_eq!(Backend::from_id(other), None, "{other}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With nothing chosen the process is on solidcore. (Only solidcore is set here: the other tests
|
||||
/// in this binary build bodies meanwhile.)
|
||||
#[test]
|
||||
fn the_default_is_solidcore() {
|
||||
if overridden() {
|
||||
return;
|
||||
}
|
||||
set_choice(Backend::Solidcore);
|
||||
assert_eq!((choice(), backend()), (Backend::Solidcore, Backend::Solidcore));
|
||||
}
|
||||
|
||||
/// The guard moves this thread alone onto the C++ kernel, and back when it drops.
|
||||
#[test]
|
||||
fn a_test_opts_in_to_the_cpp_kernel_thread_by_thread() {
|
||||
let before = backend();
|
||||
{
|
||||
let _cpp = on_cpp();
|
||||
reached_cpp();
|
||||
assert_eq!(backend(), Backend::Smlib);
|
||||
assert_eq!(choice(), before);
|
||||
}
|
||||
assert_eq!(backend(), before);
|
||||
}
|
||||
}
|
||||
@@ -3,21 +3,27 @@
|
||||
//! tagged inputs ([`Brep::tagged`]) give results whose [`Brep::history`] says where each face and
|
||||
//! edge came from.
|
||||
|
||||
use crate::{Brep, Result, error, ffi, init};
|
||||
use crate::{Brep, Result, core, error, ffi, init, route};
|
||||
|
||||
impl Brep {
|
||||
/// The faces sewn along the vertices and edges that coincide within `tolerance` mm, oriented
|
||||
/// outward where they close into a solid, else only made to agree across their shared edges;
|
||||
/// with whether the result is a closed solid.
|
||||
pub fn stitch(&self, tolerance: f64) -> Result<(Brep, bool)> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::merge::stitch(self.core()?, tolerance).map(|(b, closed)| (Brep::made_core(b), closed));
|
||||
}
|
||||
init()?;
|
||||
ffi::sheets::stitch(&self.0, tolerance).map(|(h, closed)| (Brep(h), closed)).map_err(error)
|
||||
ffi::sheets::stitch(self.cpp()?, tolerance).map(|(h, closed)| (Brep::made(h), closed)).map_err(error)
|
||||
}
|
||||
|
||||
/// This sheet with every face moved onto its surface's offset by `distance` along its normal
|
||||
/// (negative: against it). A closed solid is [`crate::Error::Argument`].
|
||||
pub fn offset_sheet(&self, distance: f64) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::offset::offset_sheet(self.core()?, distance).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::sheets::offset_sheet(&self.0, distance).map(Brep).map_err(error)
|
||||
ffi::sheets::offset_sheet(self.cpp()?, distance).map(Brep::made).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! taken over its whole domain.
|
||||
|
||||
use crate::ssi::Surface;
|
||||
use crate::{Brep, Error, Result, error, ffi, init};
|
||||
use crate::{Brep, Error, Result, core, error, ffi, init, route};
|
||||
|
||||
impl Brep {
|
||||
/// `self` with faces `faces` (indices in SMLib's face order, distinct) cut where each of
|
||||
@@ -11,14 +11,18 @@ impl Brep {
|
||||
/// for each face how many faces it became besides itself (0: no tool split it). `None` when
|
||||
/// no face is split. Tagged operands ([`Brep::tagged`]) give the pieces' history.
|
||||
pub fn split_faces(&self, faces: &[usize], tools: &[&Surface]) -> Result<Option<(Brep, Vec<usize>)>> {
|
||||
init()?;
|
||||
if faces.is_empty() || faces.len() > ffi::split::MAX_FACES || tools.is_empty() || tools.len() > ffi::split::MAX_TOOLS {
|
||||
return Err(Error::Argument);
|
||||
}
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
let specs: Vec<&crate::ssi::Spec> = tools.iter().map(|t| t.spec()).collect();
|
||||
return core::split::split_faces(self.core()?, faces, &specs).map(|r| r.map(|(b, made)| (Brep::made_core(b), made)));
|
||||
}
|
||||
init()?;
|
||||
let faces: Vec<i64> = faces.iter().map(|f| i64::try_from(*f).map_err(|_| Error::Argument)).collect::<Result<_>>()?;
|
||||
let handles: Vec<&ffi::ssi::SurfaceHandle> = tools.iter().map(|t| t.handle()).collect();
|
||||
match ffi::split::split_faces(&self.0, &faces, &handles) {
|
||||
Ok((h, made)) => Ok(Some((Brep(h), made.into_iter().map(|m| usize::try_from(m).unwrap_or(0)).collect()))),
|
||||
let handles: Vec<&ffi::ssi::SurfaceHandle> = tools.iter().map(|t| t.handle()).collect::<Result<_>>()?;
|
||||
match ffi::split::split_faces(self.cpp()?, &faces, &handles) {
|
||||
Ok((h, made)) => Ok(Some((Brep::made(h), made.into_iter().map(|m| usize::try_from(m).unwrap_or(0)).collect()))),
|
||||
Err(ffi::ops::ERR_NO_CUT) => Ok(None),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
@@ -30,9 +34,12 @@ impl Brep {
|
||||
/// leaves both parts the whole body, with no face from the tool (tag `self` to tell); `None`
|
||||
/// when a side is left empty.
|
||||
pub fn split_body(&self, tool: &Surface) -> Result<Option<(Brep, Brep)>> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::split::split_body(self.core()?, tool.spec()).map(|r| r.map(|(b, f)| (Brep::made_core(b), Brep::made_core(f))));
|
||||
}
|
||||
init()?;
|
||||
match ffi::split::split_body(&self.0, tool.handle()) {
|
||||
Ok((b, f)) => Ok(Some((Brep(b), Brep(f)))),
|
||||
match ffi::split::split_body(self.cpp()?, tool.handle()?) {
|
||||
Ok((b, f)) => Ok(Some((Brep::made(b), Brep::made(f)))),
|
||||
Err(ffi::ops::ERR_NO_CUT) => Ok(None),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
@@ -44,10 +51,14 @@ impl Brep {
|
||||
/// normal and the part in front. A `sheet` that is no sheet, or a `face` it does not have, is
|
||||
/// [`Error::Argument`].
|
||||
pub fn split_body_by_sheet(&self, sheet: &Brep, face: usize) -> Result<Option<(Brep, Brep)>> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::split::split_body_by_sheet(self.core()?, sheet.core()?, face)
|
||||
.map(|r| r.map(|(b, f)| (Brep::made_core(b), Brep::made_core(f))));
|
||||
}
|
||||
init()?;
|
||||
let face = i64::try_from(face).map_err(|_| Error::Argument)?;
|
||||
match ffi::split::split_body_by_sheet(&self.0, &sheet.0, face) {
|
||||
Ok((b, f)) => Ok(Some((Brep(b), Brep(f)))),
|
||||
match ffi::split::split_body_by_sheet(self.cpp()?, sheet.cpp()?, face) {
|
||||
Ok((b, f)) => Ok(Some((Brep::made(b), Brep::made(f)))),
|
||||
Err(ffi::ops::ERR_NO_CUT) => Ok(None),
|
||||
Err(s) => Err(error(s)),
|
||||
}
|
||||
|
||||
+233
-32
@@ -8,10 +8,30 @@
|
||||
|
||||
use solidcraft_geom::brep as b;
|
||||
|
||||
use crate::{Error, Result, error, ffi, init};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// A surface with its UV domain, owned on the C side.
|
||||
pub struct Surface(ffi::ssi::SurfaceHandle);
|
||||
use crate::{Error, Result, error, ffi, init, route};
|
||||
|
||||
/// A surface with its UV domain: the description it was made from, from which solidcore makes its
|
||||
/// own ([`crate::core::split`]), and the C side's, made when the C++ kernel needs it.
|
||||
pub struct Surface {
|
||||
handle: OnceLock<Result<ffi::ssi::SurfaceHandle>>,
|
||||
/// solidcore's, made when it is first needed.
|
||||
core: OnceLock<Result<crate::core::split::Tool>>,
|
||||
spec: Spec,
|
||||
}
|
||||
|
||||
/// What a [`Surface`] was made from: its constructor's arguments, checked as the C API checks them
|
||||
/// ([`Spec::check`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Spec {
|
||||
Plane { origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], u: [f64; 2], v: [f64; 2] },
|
||||
Cylinder { origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, angle_deg: [f64; 2], height: [f64; 2] },
|
||||
Cone { apex: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], half_angle_deg: f64, height: [f64; 2] },
|
||||
Sphere { center: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, lon_deg: [f64; 2], lat_deg: [f64; 2] },
|
||||
Torus { center: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], major_radius: f64, minor_radius: f64, major_deg: [f64; 2], minor_deg: [f64; 2] },
|
||||
Nurbs { degree: [usize; 2], nu: usize, nv: usize, knots_u: Vec<f64>, knots_v: Vec<f64>, points: Vec<[f64; 3]>, weights: Option<Vec<f64>> },
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Surface {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -23,35 +43,166 @@ fn int(n: usize) -> Result<i32> {
|
||||
i32::try_from(n).map_err(|_| Error::Argument)
|
||||
}
|
||||
|
||||
/// The largest coordinate, degree and control net the C API takes (`SMLIB_SURFACE_MAX_*`).
|
||||
const MAX_COORD: f64 = 1e8;
|
||||
const MAX_DEGREE: usize = 25;
|
||||
const MAX_POLES: usize = 1_000_000;
|
||||
|
||||
fn bounded(v: &[f64]) -> bool {
|
||||
v.iter().all(|x| x.is_finite() && x.abs() <= MAX_COORD)
|
||||
}
|
||||
|
||||
/// Two non-zero axes that are not (nearly) parallel.
|
||||
fn axes(x: &[f64; 3], y: &[f64; 3]) -> bool {
|
||||
let len = |a: &[f64; 3]| (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
|
||||
let c = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]];
|
||||
bounded(x) && bounded(y) && len(x) > 1e-12 && len(y) > 1e-12 && len(&c) > 1e-9 * len(x) * len(y)
|
||||
}
|
||||
|
||||
/// An increasing interval inside `[lo, hi]` spanning at most `span`.
|
||||
fn range(r: &[f64; 2], lo: f64, hi: f64, span: f64) -> bool {
|
||||
bounded(r) && r[0] < r[1] && r[0] >= lo && r[1] <= hi && r[1] - r[0] <= span
|
||||
}
|
||||
|
||||
fn length(x: f64) -> bool {
|
||||
x.is_finite() && x > 1e-9 && x <= MAX_COORD
|
||||
}
|
||||
|
||||
/// A knot vector of n + p + 1 non-decreasing values with a non-empty domain `[k[p], k[n]]`, no
|
||||
/// interior knot repeated more than p times and no end knot more than p + 1.
|
||||
fn knots(k: &[f64], n: usize, p: usize) -> bool {
|
||||
if k.len() != n + p + 1 || !bounded(k) || k.windows(2).any(|w| w[1] < w[0]) {
|
||||
return false;
|
||||
}
|
||||
let (Some(lo), Some(hi)) = (k.get(p).copied(), k.get(n).copied()) else { return false };
|
||||
let mut run = 1;
|
||||
for w in k.windows(2) {
|
||||
run = if w[1] == w[0] { run + 1 } else { 1 };
|
||||
if run > p + 1 || (run > p && w[1] > lo && w[1] < hi) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
lo < hi
|
||||
}
|
||||
|
||||
impl Spec {
|
||||
/// The checks each of the C API's constructors makes of its arguments, so that a surface is
|
||||
/// refused alike on either kernel, and without the C++ kernel.
|
||||
fn check(&self) -> Result<()> {
|
||||
let whole = |r: &[f64; 2]| range(r, -MAX_COORD, MAX_COORD, 2.0 * MAX_COORD);
|
||||
let turn = |r: &[f64; 2]| range(r, -360.0, 360.0, 360.0);
|
||||
let ok = match self {
|
||||
Spec::Plane { origin, xaxis, yaxis, u, v } => bounded(origin) && axes(xaxis, yaxis) && whole(u) && whole(v),
|
||||
Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => {
|
||||
bounded(origin) && axes(xaxis, yaxis) && length(*radius) && turn(angle_deg) && range(height, -MAX_COORD, MAX_COORD, MAX_COORD)
|
||||
}
|
||||
Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => {
|
||||
bounded(apex)
|
||||
&& axes(xaxis, yaxis)
|
||||
&& (0.01..=89.0).contains(half_angle_deg)
|
||||
&& range(height, 0.0, MAX_COORD, MAX_COORD)
|
||||
&& height[1] - height[0] > 1e-9
|
||||
}
|
||||
Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => {
|
||||
bounded(center) && axes(xaxis, yaxis) && length(*radius) && turn(lon_deg) && range(lat_deg, -90.0, 90.0, 180.0)
|
||||
}
|
||||
Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => {
|
||||
bounded(center)
|
||||
&& axes(xaxis, yaxis)
|
||||
&& length(*major_radius)
|
||||
&& length(*minor_radius)
|
||||
&& minor_radius < major_radius
|
||||
&& turn(major_deg)
|
||||
&& turn(minor_deg)
|
||||
}
|
||||
Spec::Nurbs { degree: [p, q], nu, nv, knots_u, knots_v, points, weights } => {
|
||||
(1..=MAX_DEGREE).contains(p)
|
||||
&& (1..=MAX_DEGREE).contains(q)
|
||||
&& *nu > *p
|
||||
&& *nv > *q
|
||||
&& nu.checked_mul(*nv).is_some_and(|n| n <= MAX_POLES && points.len() == n)
|
||||
&& knots(knots_u, *nu, *p)
|
||||
&& knots(knots_v, *nv, *q)
|
||||
&& points.iter().all(|c| bounded(c))
|
||||
&& weights.as_ref().is_none_or(|w| w.len() == points.len() && w.iter().all(|x| (1e-6..=1e6).contains(x)))
|
||||
}
|
||||
};
|
||||
if ok { Ok(()) } else { Err(Error::Argument) }
|
||||
}
|
||||
|
||||
/// The C side's surface.
|
||||
fn on_cpp(&self) -> Result<ffi::ssi::SurfaceHandle> {
|
||||
init()?;
|
||||
match self {
|
||||
Spec::Plane { origin, xaxis, yaxis, u, v } => ffi::ssi::plane(origin, xaxis, yaxis, u, v),
|
||||
Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => {
|
||||
ffi::ssi::cylinder(origin, xaxis, yaxis, *radius, angle_deg, height)
|
||||
}
|
||||
Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => ffi::ssi::cone(apex, xaxis, yaxis, *half_angle_deg, height),
|
||||
Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => ffi::ssi::sphere(center, xaxis, yaxis, *radius, lon_deg, lat_deg),
|
||||
Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => {
|
||||
ffi::ssi::torus(center, xaxis, yaxis, *major_radius, *minor_radius, major_deg, minor_deg)
|
||||
}
|
||||
Spec::Nurbs { degree, nu, nv, knots_u, knots_v, points, weights } => {
|
||||
let flat: Vec<f64> = points.iter().flatten().copied().collect();
|
||||
ffi::ssi::nurbs([int(degree[0])?, int(degree[1])?], int(*nu)?, int(*nv)?, knots_u, knots_v, &flat, weights.as_deref())
|
||||
}
|
||||
}
|
||||
.map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether intersections run on the C++ kernel ([`route::backend`]).
|
||||
fn ssi_on_cpp() -> bool {
|
||||
route::backend() == route::Backend::Smlib
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
pub(crate) fn handle(&self) -> &ffi::ssi::SurfaceHandle {
|
||||
&self.0
|
||||
/// The C side's surface, made the first time the C++ kernel needs it.
|
||||
pub(crate) fn handle(&self) -> Result<&ffi::ssi::SurfaceHandle> {
|
||||
self.handle.get_or_init(|| self.spec.on_cpp()).as_ref().map_err(Clone::clone)
|
||||
}
|
||||
|
||||
/// solidcore's surface, made the first time it is needed.
|
||||
fn core(&self) -> Result<&crate::core::split::Tool> {
|
||||
self.core.get_or_init(|| crate::core::ssi::surface(&self.spec)).as_ref().map_err(Clone::clone)
|
||||
}
|
||||
|
||||
pub(crate) fn spec(&self) -> &Spec {
|
||||
&self.spec
|
||||
}
|
||||
|
||||
/// A surface from its checked description. On the C++
|
||||
/// kernel, its side is made now, so that what it refuses is refused here.
|
||||
fn made(spec: Spec) -> Result<Surface> {
|
||||
spec.check()?;
|
||||
let s = Surface { handle: OnceLock::new(), core: OnceLock::new(), spec };
|
||||
if ssi_on_cpp() {
|
||||
s.handle()?;
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// `origin + u*x + v*y` (x, y the axes made unit), for u in `u`, v in `v`.
|
||||
pub fn plane(origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], u: [f64; 2], v: [f64; 2]) -> Result<Surface> {
|
||||
init()?;
|
||||
ffi::ssi::plane(&origin, &xaxis, &yaxis, &u, &v).map(Surface).map_err(error)
|
||||
Surface::made(Spec::Plane { origin, xaxis, yaxis, u, v })
|
||||
}
|
||||
|
||||
/// A cylinder of `radius` about the axis through `origin` along `xaxis × yaxis`, angle
|
||||
/// `angle_deg` from `xaxis` towards `yaxis`, for heights `height` measured from `origin`.
|
||||
pub fn cylinder(origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, angle_deg: [f64; 2], height: [f64; 2]) -> Result<Surface> {
|
||||
init()?;
|
||||
ffi::ssi::cylinder(&origin, &xaxis, &yaxis, radius, &angle_deg, &height).map(Surface).map_err(error)
|
||||
Surface::made(Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height })
|
||||
}
|
||||
|
||||
/// A full cone with its apex at `apex` along `xaxis × yaxis`, radius `h * tan(half_angle_deg)`
|
||||
/// at distance h along the axis, for h in `height` (from 0: through the apex).
|
||||
pub fn cone(apex: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], half_angle_deg: f64, height: [f64; 2]) -> Result<Surface> {
|
||||
init()?;
|
||||
ffi::ssi::cone(&apex, &xaxis, &yaxis, half_angle_deg, &height).map(Surface).map_err(error)
|
||||
Surface::made(Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height })
|
||||
}
|
||||
|
||||
/// A sphere of `radius` at `center`, over longitudes `lon_deg` and latitudes `lat_deg`.
|
||||
pub fn sphere(center: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, lon_deg: [f64; 2], lat_deg: [f64; 2]) -> Result<Surface> {
|
||||
init()?;
|
||||
ffi::ssi::sphere(¢er, &xaxis, &yaxis, radius, &lon_deg, &lat_deg).map(Surface).map_err(error)
|
||||
Surface::made(Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg })
|
||||
}
|
||||
|
||||
/// A ring torus at `center` about `xaxis × yaxis`, `major_deg` round the axis and `minor_deg`
|
||||
@@ -65,8 +216,7 @@ impl Surface {
|
||||
major_deg: [f64; 2],
|
||||
minor_deg: [f64; 2],
|
||||
) -> Result<Surface> {
|
||||
init()?;
|
||||
ffi::ssi::torus(¢er, &xaxis, &yaxis, major_radius, minor_radius, &major_deg, &minor_deg).map(Surface).map_err(error)
|
||||
Surface::made(Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg })
|
||||
}
|
||||
|
||||
/// A (rational) B-spline surface: `nu × nv` control points, point (i, j) (i along u) at
|
||||
@@ -80,9 +230,15 @@ impl Surface {
|
||||
points: &[[f64; 3]],
|
||||
weights: Option<&[f64]>,
|
||||
) -> Result<Surface> {
|
||||
init()?;
|
||||
let flat: Vec<f64> = points.iter().flatten().copied().collect();
|
||||
ffi::ssi::nurbs([int(degree[0])?, int(degree[1])?], int(nu)?, int(nv)?, knots_u, knots_v, &flat, weights).map(Surface).map_err(error)
|
||||
Surface::made(Spec::Nurbs {
|
||||
degree,
|
||||
nu,
|
||||
nv,
|
||||
knots_u: knots_u.to_vec(),
|
||||
knots_v: knots_v.to_vec(),
|
||||
points: points.to_vec(),
|
||||
weights: weights.map(<[f64]>::to_vec),
|
||||
})
|
||||
}
|
||||
|
||||
/// A schema 4 record surface over the parameter box `domain` (`[[u0, u1], [v0, v1]]`, the
|
||||
@@ -142,12 +298,18 @@ impl Surface {
|
||||
|
||||
/// The UV domain the intersection runs within, as `[u0, u1, v0, v1]`.
|
||||
pub fn domain(&self) -> Result<[f64; 4]> {
|
||||
ffi::ssi::domain(&self.0).map_err(error)
|
||||
if !ssi_on_cpp() {
|
||||
return self.core().map(crate::core::ssi::domain);
|
||||
}
|
||||
ffi::ssi::domain(self.handle()?).map_err(error)
|
||||
}
|
||||
|
||||
/// The surface's point at (u, v).
|
||||
pub fn evaluate(&self, u: f64, v: f64) -> Result<[f64; 3]> {
|
||||
ffi::ssi::evaluate(&self.0, u, v).map_err(error)
|
||||
if !ssi_on_cpp() {
|
||||
return crate::core::ssi::evaluate(self.core()?, u, v);
|
||||
}
|
||||
ffi::ssi::evaluate(self.handle()?, u, v).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,8 +320,6 @@ pub enum Method {
|
||||
Global,
|
||||
/// `SmAdvSurfaceIntersector`, the general NURBS path.
|
||||
Advanced,
|
||||
/// `SmSurfaceIntersector`, the basic intersector.
|
||||
Basic,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
@@ -167,7 +327,6 @@ impl Method {
|
||||
match self {
|
||||
Method::Global => 0,
|
||||
Method::Advanced => 1,
|
||||
Method::Basic => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +403,7 @@ pub struct SsiResult {
|
||||
}
|
||||
|
||||
/// The 3D B-spline: polynomial where every weight is 1.
|
||||
fn curve3(c: ffi::ssi::Nurbs) -> b::Curve {
|
||||
pub(crate) fn curve3(c: ffi::ssi::Nurbs) -> b::Curve {
|
||||
let points = c.points.as_chunks::<3>().0;
|
||||
if c.weights.iter().all(|w| *w == 1.0) {
|
||||
b::Curve::Spline { knots: c.knots, points: points.to_vec() }
|
||||
@@ -257,7 +416,7 @@ fn curve3(c: ffi::ssi::Nurbs) -> b::Curve {
|
||||
}
|
||||
|
||||
/// A UV B-spline, as [`curve3`].
|
||||
fn curve2(c: ffi::ssi::Nurbs) -> b::Curve2 {
|
||||
pub(crate) fn curve2(c: ffi::ssi::Nurbs) -> b::Curve2 {
|
||||
let points = c.points.as_chunks::<2>().0;
|
||||
if c.weights.iter().all(|w| *w == 1.0) {
|
||||
b::Curve2::Spline { knots: c.knots, points: points.to_vec() }
|
||||
@@ -279,7 +438,10 @@ fn uv(r: &ffi::ssi::ResultHandle, i: i32, which: i32) -> Result<Option<b::Curve2
|
||||
/// `approx_tol` (<= 0: SMLib's default, 1/1000 of the surfaces' size). An intersector failure is
|
||||
/// SMLib's status; a curve SMLib does not hold as a B-spline is [`Error::Unsupported`].
|
||||
pub fn intersect(a: &Surface, b: &Surface, method: Method, approx_tol: f64) -> Result<SsiResult> {
|
||||
let r = ffi::ssi::ssi(&a.0, &b.0, method.code(), approx_tol).map_err(error)?;
|
||||
if !ssi_on_cpp() {
|
||||
return crate::core::ssi::intersect(a.core()?, b.core()?, method, approx_tol);
|
||||
}
|
||||
let r = ffi::ssi::ssi(a.handle()?, b.handle()?, method.code(), approx_tol).map_err(error)?;
|
||||
let n = ffi::ssi::curve_count(&r).map_err(error)?;
|
||||
let mut curves = Vec::new();
|
||||
for i in 0..n {
|
||||
@@ -309,6 +471,8 @@ mod tests {
|
||||
/// A plane through a sphere's centre: one great circle.
|
||||
#[test]
|
||||
fn plane_through_sphere_is_a_circle() {
|
||||
#[cfg(feature = "native")]
|
||||
let _cpp = route::on_cpp();
|
||||
let z = [0.0, 0.0, 1.0];
|
||||
let (x, y) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
|
||||
let plane = Surface::plane([0.0; 3], x, y, [-2.0, 2.0], [-2.0, 2.0]);
|
||||
@@ -330,15 +494,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refused here, before any kernel: alike on either, and with no C++ kernel.
|
||||
#[test]
|
||||
fn bad_input_is_an_argument_error() {
|
||||
match Surface::plane([0.0; 3], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0], [0.0, 1.0]) {
|
||||
Err(Error::NotBuilt) => {}
|
||||
r => assert_eq!(r.err(), Some(Error::Argument)),
|
||||
let (x, y) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]);
|
||||
let refused = [
|
||||
Surface::plane([0.0; 3], x, x, [0.0, 1.0], [0.0, 1.0]),
|
||||
Surface::plane([0.0; 3], x, y, [1.0, 0.0], [0.0, 1.0]),
|
||||
Surface::plane([f64::NAN; 3], x, y, [0.0, 1.0], [0.0, 1.0]),
|
||||
Surface::cylinder([0.0; 3], x, y, 0.0, [0.0, 360.0], [0.0, 1.0]),
|
||||
Surface::cone([0.0; 3], x, y, f64::NAN, [0.0, 1.0]),
|
||||
Surface::sphere([0.0; 3], x, y, 1.0, [0.0, 360.0], [-91.0, 90.0]),
|
||||
Surface::torus([0.0; 3], x, y, 1.0, 2.0, [0.0, 360.0], [0.0, 360.0]),
|
||||
Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 3], None),
|
||||
Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 4], None),
|
||||
Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 4], Some(&[0.0; 4])),
|
||||
Surface::nurbs([0, 1], 2, 2, &[0.0, 0.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 4], None),
|
||||
];
|
||||
for (i, r) in refused.iter().enumerate() {
|
||||
assert_eq!(r.as_ref().err(), Some(&Error::Argument), "{i}");
|
||||
}
|
||||
match Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 3], None) {
|
||||
Err(Error::NotBuilt) => {}
|
||||
r => assert_eq!(r.err(), Some(Error::Argument)),
|
||||
}
|
||||
|
||||
/// On solidcore a surface needs no C++ kernel, and answers by itself. The basic intersector is
|
||||
/// the one method solidcore has no public entry for, and it says so; nothing falls back.
|
||||
#[test]
|
||||
fn surfaces_on_solidcore_answer_without_the_cpp_kernel() {
|
||||
if ssi_on_cpp() {
|
||||
return;
|
||||
}
|
||||
let (x, y, z) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]);
|
||||
let plane = Surface::plane([0.0; 3], x, y, [-2.0, 2.0], [-3.0, 3.0]).unwrap();
|
||||
assert_eq!(plane.domain().unwrap(), [-2.0, 2.0, -3.0, 3.0]);
|
||||
assert_eq!(plane.evaluate(1.0, -2.0).unwrap(), [1.0, -2.0, 0.0]);
|
||||
assert_eq!(plane.evaluate(f64::NAN, 0.0).unwrap_err(), Error::Argument);
|
||||
let sphere = Surface::sphere([0.0; 3], x, z, 1.0, [0.0, 360.0], [-90.0, 90.0]).unwrap();
|
||||
for method in [Method::Global, Method::Advanced] {
|
||||
let r = intersect(&plane, &sphere, method, 1e-6).unwrap();
|
||||
assert!(!r.curves.is_empty(), "{method:?}");
|
||||
for c in &r.curves {
|
||||
let eval = b::eval::CurveEval::new(&c.curve).unwrap();
|
||||
let [t0, t1] = c.interval;
|
||||
for k in 0..=100 {
|
||||
let p = eval.point(t0 + (t1 - t0) * f64::from(k) / 100.0).unwrap();
|
||||
assert!((p.x.hypot(p.y) - 1.0).abs() < 1e-5 && p.z.abs() < 1e-5, "{method:?}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+67
-16
@@ -6,7 +6,7 @@
|
||||
//! With `cap`, the profile's loops close the ends and the result must be a closed manifold solid
|
||||
//! ([`Error::NotSolid`] otherwise); without, it is the swept sheet.
|
||||
|
||||
use crate::{Brep, Error, Result, error, ffi, init};
|
||||
use crate::{Brep, Error, Result, core, error, ffi, init, route};
|
||||
|
||||
/// A 3D curve of a profile, path or section.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -99,13 +99,13 @@ pub const MAX_CURVES: usize = ffi::sweeps::CURVE_MAX_CURVES;
|
||||
|
||||
/// The curves as the C API's curve list.
|
||||
#[derive(Default)]
|
||||
struct List {
|
||||
pub(crate) struct List {
|
||||
kinds: Vec<i64>,
|
||||
reals: Vec<f64>,
|
||||
}
|
||||
|
||||
impl List {
|
||||
fn of<'a>(curves: impl IntoIterator<Item = &'a Curve>) -> Result<List> {
|
||||
pub(crate) fn of<'a>(curves: impl IntoIterator<Item = &'a Curve>) -> Result<List> {
|
||||
let mut l = List::default();
|
||||
for c in curves {
|
||||
l.push(c)?;
|
||||
@@ -150,18 +150,36 @@ impl List {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn raw(&self) -> ffi::sweeps::List<'_> {
|
||||
pub(crate) fn raw(&self) -> ffi::sweeps::List<'_> {
|
||||
ffi::sweeps::List::new(&self.kinds, &self.reals)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: this thread's sweeps run on the C++ kernel whatever the choice.
|
||||
pub(crate) static ON_CPP: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Whether the sweeps run on solidcore ([`route::backend`]).
|
||||
fn on_core() -> bool {
|
||||
#[cfg(test)]
|
||||
if ON_CPP.with(std::cell::Cell::get) {
|
||||
return false;
|
||||
}
|
||||
route::backend() == route::Backend::Solidcore
|
||||
}
|
||||
|
||||
impl Brep {
|
||||
/// A sheet of planar faces bounded by `loops`: closed loops of curves in one plane, in any
|
||||
/// order, nested as outer loops, their holes and islands in the holes.
|
||||
pub fn planar_faces(loops: &[Curve]) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::planar_faces(loops).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(loops)?;
|
||||
ffi::sweeps::planar_faces(&l.raw()).map(Brep).map_err(error)
|
||||
ffi::sweeps::planar_faces(&l.raw()).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// The planar `profile` extruded `distance` along the unit `direction` (square to its plane).
|
||||
@@ -172,6 +190,9 @@ impl Brep {
|
||||
/// [`DraftCorner::Curvature`], a curve of a profile with a sharp corner is neither a line nor
|
||||
/// an arc).
|
||||
pub fn extrude(profile: &[Curve], direction: [f64; 3], distance: f64, draft: f64, corner: DraftCorner, cap: bool) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::extrude(profile, direction, distance, draft, corner, cap).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(profile)?;
|
||||
let rule = match corner {
|
||||
@@ -180,7 +201,7 @@ impl Brep {
|
||||
DraftCorner::Round => ffi::sweeps::DRAFT_CORNER_ROUND,
|
||||
};
|
||||
let mut at = [f64::NAN; 3];
|
||||
ffi::sweeps::extrude(&l.raw(), &direction, distance, draft, rule, cap, &mut at).map(Brep).map_err(|s| {
|
||||
ffi::sweeps::extrude(&l.raw(), &direction, distance, draft, rule, cap, &mut at).map(Brep::made).map_err(|s| {
|
||||
let why = match s {
|
||||
ffi::sweeps::ERR_CORNER_APART => Some(CornerRefusal::Apart),
|
||||
ffi::sweeps::ERR_WALL_CLOSES => Some(CornerRefusal::WallCloses),
|
||||
@@ -197,22 +218,28 @@ impl Brep {
|
||||
/// The planar `profile` turned `angle` radians (0 < angle <= 2 pi) right-handed about the
|
||||
/// axis through `origin` along `direction`, in the profile's plane (touching it at most).
|
||||
pub fn revolve(profile: &[Curve], origin: [f64; 3], direction: [f64; 3], angle: f64, cap: bool) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::revolve(profile, origin, direction, angle, cap).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(profile)?;
|
||||
ffi::sweeps::revolve(&l.raw(), &origin, &direction, angle, cap).map(Brep).map_err(error)
|
||||
ffi::sweeps::revolve(&l.raw(), &origin, &direction, angle, cap).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// `profile` swept along `path` (end-to-end curves), carried with the path's start: the
|
||||
/// profile keeps its place relative to it. The walls are B-spline approximations, one per
|
||||
/// stretch of profile between tangent breaks.
|
||||
pub fn sweep(profile: &[Curve], path: &[Curve], frame: SweepFrame, cap: bool) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::sweep_placed(profile, path, frame, cap).map(|(b, _)| Brep::made_core(b));
|
||||
}
|
||||
init()?;
|
||||
let (l, p) = (List::of(profile)?, List::of(path)?);
|
||||
let frame = match frame {
|
||||
SweepFrame::Follow => ffi::sweeps::SWEEP_FOLLOW,
|
||||
SweepFrame::Translate => ffi::sweeps::SWEEP_TRANSLATE,
|
||||
};
|
||||
ffi::sweeps::sweep(&l.raw(), &p.raw(), frame, cap).map(Brep).map_err(error)
|
||||
ffi::sweeps::sweep(&l.raw(), &p.raw(), frame, cap).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// [`Brep::sweep`], with where the sweep placed the profile: the rigid motions carrying the
|
||||
@@ -221,6 +248,9 @@ impl Brep {
|
||||
/// out from point to point along the path); a sweep that carries on from where this one left
|
||||
/// the profile starts from the last.
|
||||
pub fn sweep_placed(profile: &[Curve], path: &[Curve], frame: SweepFrame, cap: bool) -> Result<(Brep, [Affine; 3])> {
|
||||
if on_core() {
|
||||
return core::sweep::sweep_placed(profile, path, frame, cap).map(|(b, m)| (Brep::made_core(b), m));
|
||||
}
|
||||
init()?;
|
||||
let (l, p) = (List::of(profile)?, List::of(path)?);
|
||||
let frame = match frame {
|
||||
@@ -228,7 +258,7 @@ impl Brep {
|
||||
SweepFrame::Translate => ffi::sweeps::SWEEP_TRANSLATE,
|
||||
};
|
||||
let mut placed = [0.0; 36];
|
||||
let brep = ffi::sweeps::sweep_placed(&l.raw(), &p.raw(), frame, cap, &mut placed).map(Brep).map_err(error)?;
|
||||
let brep = ffi::sweeps::sweep_placed(&l.raw(), &p.raw(), frame, cap, &mut placed).map(Brep::made).map_err(error)?;
|
||||
let motion = |i: usize| {
|
||||
let at = |k: usize| placed.get(12 * i + k).copied().unwrap_or(0.0);
|
||||
Affine { m: [[at(0), at(1), at(2)], [at(3), at(4), at(5)], [at(6), at(7), at(8)]], t: [at(9), at(10), at(11)] }
|
||||
@@ -249,9 +279,12 @@ impl Brep {
|
||||
direction: [f64; 3],
|
||||
tolerance: f64,
|
||||
) -> Result<Vec<(Curve, [f64; 2])>> {
|
||||
if on_core() {
|
||||
return core::sweep::project_curves(self.core()?, face_point, pick_tolerance, curves, direction, tolerance);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(curves)?;
|
||||
let out = ffi::sweeps::project_curves(&self.0, &face_point, pick_tolerance, &l.raw(), &direction, tolerance).map_err(picked)?;
|
||||
let out = ffi::sweeps::project_curves(self.cpp()?, &face_point, pick_tolerance, &l.raw(), &direction, tolerance).map_err(picked)?;
|
||||
nurbs_list(&out)
|
||||
}
|
||||
|
||||
@@ -265,14 +298,20 @@ impl Brep {
|
||||
plane_normal: [f64; 3],
|
||||
tolerance: f64,
|
||||
) -> Result<Vec<(Curve, [f64; 2])>> {
|
||||
if on_core() {
|
||||
return core::sweep::face_section(self.core()?, face_point, pick_tolerance, plane_point, plane_normal, tolerance);
|
||||
}
|
||||
init()?;
|
||||
let out = ffi::sweeps::face_section(&self.0, &face_point, pick_tolerance, &plane_point, &plane_normal, tolerance).map_err(picked)?;
|
||||
let out = ffi::sweeps::face_section(self.cpp()?, &face_point, pick_tolerance, &plane_point, &plane_normal, tolerance).map_err(picked)?;
|
||||
nurbs_list(&out)
|
||||
}
|
||||
|
||||
/// A loft (skin) through `sections` (at least two closed loops), `degree` (1 ruled, 2 or 3)
|
||||
/// across them.
|
||||
pub fn loft(sections: &[Vec<Curve>], degree: u32, cap: bool) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::loft(sections, degree, cap).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
if sections.len() < 2 || sections.len() > MAX_CURVES || !(1..=3).contains(°ree) {
|
||||
return Err(Error::Argument);
|
||||
@@ -280,13 +319,16 @@ impl Brep {
|
||||
let l = List::of(sections.iter().flatten())?;
|
||||
let sizes: Vec<i64> = sections.iter().map(|s| i64::try_from(s.len()).map_err(|_| Error::Argument)).collect::<Result<_>>()?;
|
||||
let degree = i32::try_from(degree).map_err(|_| Error::Argument)?;
|
||||
ffi::sweeps::loft(&l.raw(), &sizes, degree, cap).map(Brep).map_err(error)
|
||||
ffi::sweeps::loft(&l.raw(), &sizes, degree, cap).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// The planar `profile` placed by each of `stations` in turn (2…[`MAX_STATIONS`], each
|
||||
/// section exactly the profile's image), its walls skinned through the sections, cubic
|
||||
/// across them: one wall per profile curve. `ends` says how the ends close.
|
||||
pub fn sweep_stations(profile: &[Curve], stations: &[Affine], ends: Ends) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::sweep_stations(profile, stations, ends).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
if !(2..=MAX_STATIONS).contains(&stations.len()) || profile.len().saturating_mul(stations.len()) > MAX_STATION_CURVES {
|
||||
return Err(Error::Argument);
|
||||
@@ -298,24 +340,30 @@ impl Brep {
|
||||
Ends::Capped => ffi::sweeps::ENDS_CAPPED,
|
||||
Ends::Joined => ffi::sweeps::ENDS_JOINED,
|
||||
};
|
||||
ffi::sweeps::sweep_stations(&l.raw(), &maps, ends).map(Brep).map_err(error)
|
||||
ffi::sweeps::sweep_stations(&l.raw(), &maps, ends).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// A loft from the planar `section` (one closed loop of lines, in order) to the point `apex`
|
||||
/// off its plane: a pyramid, its base the section and a triangle on each line.
|
||||
pub fn loft_to_point(section: &[Curve], apex: [f64; 3]) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::loft_to_point(section, apex).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(section)?;
|
||||
ffi::sweeps::loft_to_point(&l.raw(), &apex).map(Brep).map_err(error)
|
||||
ffi::sweeps::loft_to_point(&l.raw(), &apex).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// `profile` carried by a screw motion about the axis through `origin` along `direction`:
|
||||
/// `turns` right-handed turns about it, rising `pitch` (signed) per turn, capped at both ends,
|
||||
/// its walls within about `tolerance` of the true screw surfaces.
|
||||
pub fn screw_sweep(profile: &[Curve], origin: [f64; 3], direction: [f64; 3], pitch: f64, turns: f64, tolerance: f64) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::screw_sweep(profile, origin, direction, pitch, turns, None, tolerance).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(profile)?;
|
||||
ffi::sweeps::screw_sweep(&l.raw(), &origin, &direction, pitch, turns, tolerance).map(Brep).map_err(error)
|
||||
ffi::sweeps::screw_sweep(&l.raw(), &origin, &direction, pitch, turns, tolerance).map(Brep::made).map_err(error)
|
||||
}
|
||||
|
||||
/// [`screw_sweep`](Self::screw_sweep) with the profile moved off the axis as it turns: at turn
|
||||
@@ -333,10 +381,13 @@ impl Brep {
|
||||
law: &[(f64, f64)],
|
||||
tolerance: f64,
|
||||
) -> Result<Brep> {
|
||||
if on_core() {
|
||||
return core::sweep::screw_sweep(profile, origin, direction, pitch, turns, Some(law), tolerance).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let l = List::of(profile)?;
|
||||
let flat: Vec<f64> = law.iter().flat_map(|(t, o)| [*t, *o]).collect();
|
||||
ffi::sweeps::screw_sweep_radial(&l.raw(), &origin, &direction, pitch, turns, &flat, tolerance).map(Brep).map_err(error)
|
||||
ffi::sweeps::screw_sweep_radial(&l.raw(), &origin, &direction, pitch, turns, &flat, tolerance).map(Brep::made).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+338
-4
@@ -9,7 +9,8 @@ fn box_end_to_end() {
|
||||
let c = b.try_clone().unwrap();
|
||||
drop(b);
|
||||
assert!((c.volume(1e-7).unwrap() - 6000.0).abs() < 1e-6);
|
||||
assert_eq!(current_call(), None, "no call in flight between calls");
|
||||
let mut buf = [0u8; 512];
|
||||
assert_eq!(ffi::current_call(&mut buf), 0, "no C call in flight between calls");
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
@@ -17,13 +18,86 @@ fn box_end_to_end() {
|
||||
fn bad_arguments_are_errors() {
|
||||
assert_eq!(Brep::make_box([0.0; 3], [1.0, -1.0, 1.0]).unwrap_err(), Error::Argument);
|
||||
assert_eq!(Brep::make_box([f64::NAN, 0.0, 0.0], [1.0; 3]).unwrap_err(), Error::Argument);
|
||||
// each kernel refuses a bad box the same way, whichever one is in force
|
||||
init().unwrap();
|
||||
for size in [[1.0, -1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, f64::NAN]] {
|
||||
assert_eq!(core::make_box([0.0; 3], size).err(), Some(Error::Argument), "solidcore: {size:?}");
|
||||
assert_eq!(ffi::make_box(&[0.0; 3], &size).map_err(error).err(), Some(Error::Argument), "C++: {size:?}");
|
||||
}
|
||||
let b = Brep::make_box([0.0; 3], [1.0; 3]).unwrap();
|
||||
assert_eq!(b.volume(f64::INFINITY).unwrap_err(), Error::Argument);
|
||||
}
|
||||
|
||||
/// `.smb` files on solidcore, in both forms and with a curved body, and a missing or corrupt file
|
||||
/// is an error, not a panic.
|
||||
#[test]
|
||||
fn solidcore_reads_back_the_files_it_writes() {
|
||||
use std::f64::consts::PI;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-core-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cylinder = core::make_cylinder([1.0, 2.0, 3.0], 2.0, 5.0).map(Brep::made_core).unwrap();
|
||||
for ascii in [false, true] {
|
||||
let path = dir.join(format!("cylinder-{ascii}.smb"));
|
||||
core::file::write(cylinder.core().unwrap(), &path, ascii).unwrap();
|
||||
let back = core::file::read(&path, ascii).map(Brep::made_core).unwrap();
|
||||
let v = core::volume(back.core().unwrap(), 1e-7).unwrap();
|
||||
assert!((v - PI * 4.0 * 5.0).abs() < 1e-5, "ascii {ascii}: {v}");
|
||||
assert_eq!(core::counts(back.core().unwrap()).unwrap(), core::counts(cylinder.core().unwrap()).unwrap(), "ascii {ascii}");
|
||||
}
|
||||
let body = cylinder.core().unwrap();
|
||||
assert!(core::file::write(body, &dir.join("missing/x.smb"), false).is_err());
|
||||
assert!(core::file::read(&dir.join("missing.smb"), false).is_err());
|
||||
let junk = dir.join("junk.smb");
|
||||
std::fs::write(&junk, b"not a B-rep file").unwrap();
|
||||
assert!(core::file::read(&junk, false).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// ASCII that is no B-rep file is refused like binary. It comes back as an empty body today:
|
||||
/// default-solidcore/bugs/ascii-junk-reads-as-empty.md.
|
||||
#[test]
|
||||
#[ignore = "solidcore: an ASCII file of junk reads as an empty B-rep instead of an error"]
|
||||
fn solidcore_refuses_an_ascii_file_of_junk() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-core-junk-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let junk = dir.join("junk.smb");
|
||||
std::fs::write(&junk, b"not a B-rep file").unwrap();
|
||||
let r = core::file::read(&junk, true);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
/// A file either kernel wrote reads on the other, with the same measure.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn each_kernel_reads_the_files_of_the_other() {
|
||||
use std::f64::consts::PI;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-cross-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let want = PI * 4.0 * 5.0;
|
||||
for ascii in [false, true] {
|
||||
let from_core = dir.join(format!("core-{ascii}.smb"));
|
||||
let body = core::make_cylinder([0.0; 3], 2.0, 5.0).unwrap();
|
||||
core::file::write(&body, &from_core, ascii).unwrap();
|
||||
crate::init().unwrap();
|
||||
let on_cpp = ffi::read(&c_path(&from_core).unwrap(), ascii).map(Brep::made).unwrap();
|
||||
let v = ffi::volume(on_cpp.cpp().unwrap(), 1e-7).unwrap();
|
||||
assert!((v - want).abs() < 1e-5, "C++ on solidcore's file, ascii {ascii}: {v}");
|
||||
let from_cpp = dir.join(format!("cpp-{ascii}.smb"));
|
||||
let cpp = ffi::make_cylinder(&[0.0; 3], 2.0, 5.0).map(Brep::made).unwrap();
|
||||
crate::check(ffi::write(cpp.cpp().unwrap(), &c_path(&from_cpp).unwrap(), ascii)).unwrap();
|
||||
let on_core = core::file::read(&from_cpp, ascii).unwrap();
|
||||
let v = core::volume(&on_core, 1e-7).unwrap();
|
||||
assert!((v - want).abs() < 1e-5, "solidcore on C++'s file, ascii {ascii}: {v}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn native_files_round_trip_and_failures_dump_their_inputs() {
|
||||
let _dumps = DUMP_DIR_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _cpp = route::on_cpp();
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let b = Brep::make_box([0.0; 3], [2.0, 3.0, 4.0]).unwrap();
|
||||
@@ -65,8 +139,265 @@ fn calls_from_many_threads_are_serialised() {
|
||||
#[test]
|
||||
fn without_native_every_call_says_so() {
|
||||
assert_eq!(init(), Err(Error::NotBuilt));
|
||||
assert_eq!(Brep::make_box([0.0; 3], [1.0; 3]).unwrap_err(), Error::NotBuilt);
|
||||
assert_eq!(current_call(), None);
|
||||
// the C API's own box, whichever kernel is in force
|
||||
assert_eq!(ffi::make_box(&[0.0; 3], &[1.0; 3]).map_err(error).err(), Some(Error::NotBuilt));
|
||||
// solidcore builds a box without SMLib; asking the C++ kernel about it says there is none
|
||||
let b = core::make_box([0.0; 3], [1.0; 3]).map(Brep::made_core).unwrap();
|
||||
assert_eq!(b.cpp().err(), Some(Error::NotBuilt));
|
||||
// and says how to get it
|
||||
assert!(!built());
|
||||
assert!(Error::NotBuilt.to_string().contains("--features smlib"), "{}", Error::NotBuilt);
|
||||
}
|
||||
|
||||
/// The dump folder is process-wide: the tests that set it take turns.
|
||||
static DUMP_DIR_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Which public entry points work with no C++ kernel: all of them but [`CPP_ONLY`], which are the
|
||||
/// C++ kernel's own arrays and start-up, reached only across kernels and by tests. A new entry point
|
||||
/// fails [`every_entry_point_is_in_the_guard`] until it is called in the test below or listed.
|
||||
const CPP_ONLY: [&str; 8] = ["init", "new", "get_i64", "get_f64", "set_i64", "set_f64", "import", "export"];
|
||||
|
||||
/// Entry points that ask no kernel, so have nothing to say here.
|
||||
const NO_KERNEL: [&str; 11] = [
|
||||
"inertia_tensor",
|
||||
"is_valid",
|
||||
"failed_tests",
|
||||
"native",
|
||||
"arrays_keeping_splines",
|
||||
"from_arrays",
|
||||
"from_record_keeping_splines",
|
||||
"to_native_report",
|
||||
"to_native_ordered",
|
||||
"blends",
|
||||
// The last refusal's fit, read after it: on solidcore, none yet.
|
||||
"blend_fit",
|
||||
];
|
||||
|
||||
const ANSWERING: [&str; 62] = [
|
||||
"set_dump_dir",
|
||||
"current_call",
|
||||
"make_box",
|
||||
"read",
|
||||
"write",
|
||||
"try_clone",
|
||||
"transformed",
|
||||
"volume",
|
||||
"counts",
|
||||
"boolean",
|
||||
"union_all",
|
||||
"boolean2d",
|
||||
"imprint",
|
||||
"solid_count",
|
||||
"fillet",
|
||||
"chamfer",
|
||||
"blend",
|
||||
"blend_dropping",
|
||||
"blend_setback",
|
||||
"blend_setback_open",
|
||||
"fillet_variable",
|
||||
"chamfer_sides",
|
||||
"shell",
|
||||
"offset_faces",
|
||||
"thicken",
|
||||
"merge_faces",
|
||||
"draft",
|
||||
"split_faces",
|
||||
"split_body",
|
||||
"split_body_by_sheet",
|
||||
"untrim_face",
|
||||
"extend_sheet",
|
||||
"face_points",
|
||||
"stitch",
|
||||
"offset_sheet",
|
||||
"classify_point",
|
||||
"mass_properties",
|
||||
"bounds",
|
||||
"edge_face_distance",
|
||||
"area",
|
||||
"is_manifold_solid",
|
||||
"tagged",
|
||||
"history",
|
||||
"check",
|
||||
"make_cylinder",
|
||||
"make_sphere",
|
||||
"make_cone",
|
||||
"make_torus",
|
||||
"arrays",
|
||||
"to_native",
|
||||
"planar_faces",
|
||||
"extrude",
|
||||
"revolve",
|
||||
"sweep",
|
||||
"sweep_placed",
|
||||
"project_curves",
|
||||
"face_section",
|
||||
"loft",
|
||||
"sweep_stations",
|
||||
"loft_to_point",
|
||||
"screw_sweep",
|
||||
"screw_sweep_radial",
|
||||
];
|
||||
|
||||
/// Every `pub fn` of the files that hold the crate's kernel entry points is accounted for.
|
||||
#[test]
|
||||
fn every_entry_point_is_in_the_guard() {
|
||||
let files = [
|
||||
include_str!("lib.rs"),
|
||||
include_str!("ops.rs"),
|
||||
include_str!("split.rs"),
|
||||
include_str!("trim.rs"),
|
||||
include_str!("sheets.rs"),
|
||||
include_str!("measure.rs"),
|
||||
include_str!("flat.rs"),
|
||||
include_str!("history.rs"),
|
||||
include_str!("check.rs"),
|
||||
include_str!("sweeps.rs"),
|
||||
include_str!("export.rs"),
|
||||
];
|
||||
for file in files {
|
||||
for line in file.lines() {
|
||||
let Some(rest) = line.trim_start().strip_prefix("pub fn ") else { continue };
|
||||
let name = rest.split(['(', '<']).next().unwrap_or(rest);
|
||||
assert!(ANSWERING.contains(&name) || CPP_ONLY.contains(&name) || NO_KERNEL.contains(&name), "`{name}` is in neither list");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// With no C++ kernel, each entry point answers with something other than "not built": its result
|
||||
/// or its own refusal, on a body of the right kind or the wrong one. Built with the C++ kernel, the
|
||||
/// same calls with nothing chosen never touch a C++ body: nothing on the default path reaches it.
|
||||
#[test]
|
||||
fn without_the_cpp_kernel_every_entry_point_answers() {
|
||||
use crate::sweeps::{Affine, Curve, DraftCorner, Ends, SweepFrame};
|
||||
use crate::trim::{Continuation, Holes};
|
||||
use crate::{Blend, BooleanOp, ChamferSecond, CheckLevel};
|
||||
if route::backend() != route::Backend::Solidcore {
|
||||
return;
|
||||
}
|
||||
fn says<T>(name: &str, r: Result<T>) -> Option<T> {
|
||||
match r {
|
||||
Err(Error::NotBuilt) => panic!("{name} needs the C++ kernel"),
|
||||
r => r.ok(),
|
||||
}
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-guard-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
{
|
||||
let _dumps = DUMP_DIR_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
says("set_dump_dir", set_dump_dir(None));
|
||||
}
|
||||
let _ = current_call();
|
||||
route::take_reached();
|
||||
let a = says("make_box", Brep::make_box([0.0; 3], [10.0; 3])).unwrap();
|
||||
let t = says("make_box", Brep::make_box([5.0; 3], [10.0; 3])).unwrap();
|
||||
let p = |x: f64, y: f64, z: f64| [x, y, z];
|
||||
let edge = [p(5.0, 10.0, 10.0)];
|
||||
let top = [p(5.0, 5.0, 10.0)];
|
||||
let path = dir.join("a.smb");
|
||||
says("write", a.write(&path, false));
|
||||
says("read", Brep::read(&path, false));
|
||||
says("try_clone", a.try_clone());
|
||||
says("transformed", a.transformed(&[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]));
|
||||
says("volume", a.volume(1e-6));
|
||||
says("counts", a.counts());
|
||||
says("boolean", a.boolean(&t, BooleanOp::Union));
|
||||
says("union_all", Brep::union_all(&[&a, &t]));
|
||||
says("boolean2d", a.boolean2d(&t, BooleanOp::Union));
|
||||
says("imprint", a.imprint(&t));
|
||||
says("solid_count", a.solid_count());
|
||||
says("fillet", a.fillet(&edge, 0.1, 1.0));
|
||||
says("chamfer", a.chamfer(&edge, 0.1, 1.0));
|
||||
says("blend", a.blend(Blend::Round, &edge, &[1.0], 0.1));
|
||||
says("blend_dropping", a.blend_dropping(Blend::Round, &edge, &[1.0], 0.1));
|
||||
let corner = [p(10.0, 10.0, 10.0)];
|
||||
let three = [p(5.0, 10.0, 10.0), p(10.0, 5.0, 10.0), p(10.0, 10.0, 5.0)];
|
||||
says("blend_setback", a.blend_setback(Blend::Round, &three, &[1.0; 3], &corner, &[2.0], 0.1));
|
||||
says("blend_setback_open", a.blend_setback_open(Blend::Round, &three, &[1.0; 3], &corner, &[2.0], 0.1));
|
||||
says("fillet_variable", a.fillet_variable(edge[0], 0.1, p(0.0, 10.0, 10.0), 1, &[0.0, 0.0, 1.0, 1.0], &[2.0, 1.0]));
|
||||
says("chamfer_sides", a.chamfer_sides(&edge, 0.1, 1.0, ChamferSecond::Distance(2.0), p(0.0, 0.0, 1.0)));
|
||||
says("shell", a.shell(&top, 0.1, 1.0));
|
||||
says("offset_faces", a.offset_faces(&top, 0.1, 1.0));
|
||||
says("thicken", a.thicken(1.0, false));
|
||||
says("merge_faces", a.merge_faces(&top, 0.1));
|
||||
says("draft", a.draft(&[p(5.0, 10.0, 5.0)], 0.1, [0.0; 3], p(0.0, 0.0, 1.0), p(0.0, 0.0, 1.0), 0.1));
|
||||
let tool =
|
||||
says("Surface::plane", ssi::Surface::plane(p(0.0, 0.0, 5.0), p(1.0, 0.0, 0.0), p(0.0, 1.0, 0.0), [-50.0, 50.0], [-50.0, 50.0])).unwrap();
|
||||
says("split_faces", a.split_faces(&[0], &[&tool]));
|
||||
says("split_body", a.split_body(&tool));
|
||||
let square = [p(0.0, 0.0, 5.0), p(10.0, 0.0, 5.0), p(10.0, 10.0, 5.0), p(0.0, 10.0, 5.0)];
|
||||
let lines: Vec<Curve> = (0..4).map(|i| Curve::Line { start: square[i], end: square[(i + 1) % 4] }).collect();
|
||||
let sheet = says("planar_faces", Brep::planar_faces(&lines)).unwrap();
|
||||
says("split_body_by_sheet", a.split_body_by_sheet(&sheet, 0));
|
||||
says("untrim_face", sheet.untrim_face(p(5.0, 5.0, 5.0), 0.1, Holes::All, true, false));
|
||||
says("extend_sheet", sheet.extend_sheet(p(5.0, 5.0, 5.0), 0.1, 1.0, Continuation::Natural));
|
||||
says("face_points", a.face_points());
|
||||
says("stitch", sheet.stitch(0.1));
|
||||
says("offset_sheet", sheet.offset_sheet(1.0));
|
||||
says("classify_point", a.classify_point([1.0; 3]));
|
||||
says("mass_properties", a.mass_properties(1e-6));
|
||||
says("bounds", a.bounds());
|
||||
says("edge_face_distance", a.edge_face_distance(0, 0));
|
||||
says("area", a.area(1e-6));
|
||||
says("is_manifold_solid", a.is_manifold_solid());
|
||||
says("history", says("tagged", a.tagged(0)).unwrap().history());
|
||||
says("check", a.check(CheckLevel::All));
|
||||
says("make_cylinder", Brep::make_cylinder([0.0; 3], 1.0, 1.0));
|
||||
says("make_sphere", Brep::make_sphere([0.0; 3], 1.0));
|
||||
says("make_cone", Brep::make_cone([0.0; 3], 2.0, 1.0, 1.0));
|
||||
says("make_torus", Brep::make_torus([0.0; 3], 2.0, 1.0));
|
||||
says("arrays", a.arrays());
|
||||
says("to_native", a.to_native().map_err(|_| Error::Argument));
|
||||
says("extrude", Brep::extrude(&lines, p(0.0, 0.0, 1.0), 1.0, 0.0, DraftCorner::Curvature, true));
|
||||
says("revolve", Brep::revolve(&lines, p(20.0, 0.0, 0.0), p(0.0, 1.0, 0.0), 1.0, true));
|
||||
let run = [Curve::Line { start: p(5.0, 5.0, 5.0), end: p(5.0, 5.0, 15.0) }];
|
||||
says("sweep", Brep::sweep(&lines, &run, SweepFrame::Follow, true));
|
||||
says("sweep_placed", Brep::sweep_placed(&lines, &run, SweepFrame::Follow, true));
|
||||
says("project_curves", a.project_curves(top[0], 0.1, &lines, p(0.0, 0.0, -1.0), 0.01));
|
||||
says("face_section", a.face_section(top[0], 0.1, top[0], p(1.0, 0.0, 0.0), 0.01));
|
||||
let lifted: Vec<Curve> = (0..4)
|
||||
.map(|i| Curve::Line { start: [square[i][0], square[i][1], 9.0], end: [square[(i + 1) % 4][0], square[(i + 1) % 4][1], 9.0] })
|
||||
.collect();
|
||||
says("loft", Brep::loft(&[lines.clone(), lifted], 1, true));
|
||||
let i3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
says("sweep_stations", Brep::sweep_stations(&lines, &[Affine { m: i3, t: [0.0; 3] }, Affine { m: i3, t: [0.0, 0.0, 2.0] }], Ends::Capped));
|
||||
says("loft_to_point", Brep::loft_to_point(&lines, p(5.0, 5.0, 12.0)));
|
||||
says("screw_sweep", Brep::screw_sweep(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, 0.01));
|
||||
says("screw_sweep_radial", Brep::screw_sweep_radial(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, &[(0.0, 0.0), (1.0, 0.0)], 0.01));
|
||||
let (cpp, core) = route::take_reached();
|
||||
assert!(cpp == 0 && core > 0, "the default path touched a C++ body {cpp} times ({core} solidcore calls)");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A failing solidcore call leaves its inputs and what failed, and an answered one leaves nothing;
|
||||
/// the call in flight is named, and gone after. A folder is process-wide, so this test is the one
|
||||
/// that sets it.
|
||||
#[test]
|
||||
fn a_failing_solidcore_call_leaves_a_dump_and_names_itself() {
|
||||
let _dumps = DUMP_DIR_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-smlib-core-dump-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
core::dump::set_dir(Some(dir.clone()));
|
||||
let a = core::make_box([0.0; 3], [10.0; 3]).unwrap();
|
||||
let seen: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
let r: Result<()> = core::with_body("smlib_test_failure", &a, |_, _| {
|
||||
*seen.lock().unwrap() = core::dump::current();
|
||||
Err(solidcore::SolidError::invalid_input("test", "refused"))
|
||||
});
|
||||
core::dump::set_dir(None);
|
||||
assert!(r.is_err());
|
||||
assert_eq!(seen.lock().unwrap().as_deref(), Some("smlib_test_failure"));
|
||||
{
|
||||
// Under the kernel's lock no solidcore call is in flight.
|
||||
let _kernel = core::KERNEL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
assert_eq!(core::dump::current(), None);
|
||||
}
|
||||
let got: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||
let text = got.iter().find(|n| n.ends_with("-smlib_test_failure.txt")).unwrap_or_else(|| panic!("{got:?}"));
|
||||
assert!(std::fs::read_to_string(dir.join(text)).unwrap().contains("refused"));
|
||||
let smb = got.iter().find(|n| n.ends_with("-smlib_test_failure-brep.smb")).unwrap_or_else(|| panic!("{got:?}"));
|
||||
let back = core::file::read(&dir.join(smb), false).unwrap();
|
||||
assert!((core::volume(&back, 1e-7).unwrap() - 1000.0).abs() < 1e-6);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -82,9 +413,12 @@ fn call_lines_give_the_function_and_the_c_api_time() {
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn every_call_is_a_profiler_span_timed_by_the_c_api() {
|
||||
let _cpp = route::on_cpp();
|
||||
solidcraft_profile::set_enabled(true);
|
||||
let mark = solidcraft_profile::mark();
|
||||
let b = Brep::make_box([0.0; 3], [1.0; 3]).unwrap();
|
||||
// the C API's own box, whichever kernel is in force
|
||||
crate::init().unwrap();
|
||||
let b = ffi::make_box(&[0.0; 3], &[1.0; 3]).map(Brep::made).unwrap();
|
||||
let _ = b.volume(1e-7).unwrap();
|
||||
let r = mark.report();
|
||||
let names: Vec<&str> = r.rows.iter().filter(|r| r.category == "smlib").map(|r| r.name).collect();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Untrim and extend of a sheet's faces. Each returns a new body and leaves its input unchanged;
|
||||
//! faces and holes are chosen by points, as [`crate::ops`] chooses them.
|
||||
|
||||
use crate::{Brep, Result, error, ffi, init};
|
||||
use crate::{Brep, Result, core, error, ffi, init, route};
|
||||
|
||||
/// How [`Brep::extend_sheet`] continues a surface past its side.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -29,12 +29,15 @@ impl Brep {
|
||||
/// all the way round closes on the surface's seam). The face alone, or
|
||||
/// with `in_place` the whole body with it replaced. Tags follow the face and its edges.
|
||||
pub fn untrim_face(&self, point: [f64; 3], tolerance: f64, holes: Holes, outer: bool, in_place: bool) -> Result<Brep> {
|
||||
init()?;
|
||||
let holes = match holes {
|
||||
Holes::All => None,
|
||||
Holes::At(at) => Some(at),
|
||||
};
|
||||
ffi::trim::untrim_face(&self.0, &point, tolerance, holes, outer, in_place).map(Brep).map_err(|s| match s {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::trim::untrim_face(self.core()?, point, tolerance, holes, outer, in_place).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
ffi::trim::untrim_face(self.cpp()?, &point, tolerance, holes, outer, in_place).map(Brep::made).map_err(|s| match s {
|
||||
ffi::ops::ERR_PICK_NONE => crate::Error::NoMatch { point: 0 },
|
||||
s => error(s),
|
||||
})
|
||||
@@ -45,13 +48,16 @@ impl Brep {
|
||||
/// `how` says: SMLib's extended surface over the grown box, the face and its sides carrying
|
||||
/// the tags of those they continue. A sheet of another shape is [`crate::Error::Unsupported`].
|
||||
pub fn extend_sheet(&self, point: [f64; 3], tolerance: f64, distance: f64, how: Continuation) -> Result<Brep> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::trim::extend_sheet(self.core()?, point, tolerance, distance, how).map(Brep::made_core);
|
||||
}
|
||||
init()?;
|
||||
let mode = match how {
|
||||
Continuation::Natural => ffi::trim::EXTEND_NATURAL,
|
||||
Continuation::Tangent => ffi::trim::EXTEND_TANGENT,
|
||||
Continuation::Curvature => ffi::trim::EXTEND_CURVATURE,
|
||||
};
|
||||
ffi::trim::extend_sheet(&self.0, &point, tolerance, distance, mode).map(Brep).map_err(|s| match s {
|
||||
ffi::trim::extend_sheet(self.cpp()?, &point, tolerance, distance, mode).map(Brep::made).map_err(|s| match s {
|
||||
ffi::ops::ERR_PICK_NONE => crate::Error::NoMatch { point: 0 },
|
||||
s => error(s),
|
||||
})
|
||||
@@ -60,8 +66,11 @@ impl Brep {
|
||||
/// A point inside each face, in SMLib's face order: on the face's surface, inside its trims
|
||||
/// (`SmFace::CalculateAnInternalPoint`).
|
||||
pub fn face_points(&self) -> Result<Vec<[f64; 3]>> {
|
||||
if route::backend() == route::Backend::Solidcore {
|
||||
return core::trim::face_points(self.core()?);
|
||||
}
|
||||
init()?;
|
||||
let [faces, _, _] = self.counts()?;
|
||||
ffi::trim::face_points(&self.0, faces).map_err(error)
|
||||
ffi::trim::face_points(self.cpp()?, faces).map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +367,6 @@ fn parity_with_python_scorer() {
|
||||
|
||||
/// The cases SMLib's GlobalSurfaceIntersect passes at approximation tolerance 1e-6, as score.py
|
||||
/// grades the corpus runner's output. A fix that makes another case pass adds it here.
|
||||
#[cfg(feature = "native")]
|
||||
const GSI_1E6_PASS: &[&str] = &[
|
||||
"c1_cone_plane_ellipse",
|
||||
"c1_cone_plane_hyperbola",
|
||||
@@ -483,10 +482,8 @@ const GSI_1E6_PASS: &[&str] = &[
|
||||
];
|
||||
|
||||
/// The corpus runner's samples per curve.
|
||||
#[cfg(feature = "native")]
|
||||
const RUNNER_SAMPLES: usize = 4001;
|
||||
|
||||
#[cfg(feature = "native")]
|
||||
fn surface(s: &Value) -> Result<solidcraft_smlib::ssi::Surface, String> {
|
||||
use solidcraft_smlib::ssi::Surface;
|
||||
let f = |k: &str| s.get(k).map(ssi_score::geom::num).ok_or(format!("{k}: missing"));
|
||||
@@ -535,7 +532,6 @@ fn surface(s: &Value) -> Result<solidcraft_smlib::ssi::Surface, String> {
|
||||
/// interval, each UV curve over its own, the surfaces at the UV samples), samples that cannot be
|
||||
/// evaluated dropped, empty UV arrays for a UV curve the method did not return, the runner's type
|
||||
/// names, and no isolated points (the runner only reports those in face mode).
|
||||
#[cfg(feature = "native")]
|
||||
fn runner_output(
|
||||
r: solidcraft_smlib::Result<solidcraft_smlib::ssi::SsiResult>,
|
||||
a: &solidcraft_smlib::ssi::Surface,
|
||||
@@ -581,25 +577,40 @@ fn runner_output(
|
||||
Outcome::Ran(EngineResult { status: ssi_score::SM_SUCCESS, status_name: None, time_ms: ms, curves, points: Vec::new() })
|
||||
}
|
||||
|
||||
/// Every case through SMLib's GlobalSurfaceIntersect at approximation tolerance 1e-6, scored as
|
||||
/// score.py scores the corpus runner's `gsi 1e-6` run: exactly [`GSI_1E6_PASS`] pass.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn smlib_global_intersect_at_1e_6() {
|
||||
/// One kernel at a time: the choice of kernel is process-wide.
|
||||
static KERNEL_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Every case through `backend`'s GlobalSurfaceIntersect at approximation tolerance 1e-6, scored as
|
||||
/// score.py scores the corpus runner's `gsi 1e-6` run. Prints each case that does not pass.
|
||||
/// `None` when `SOLIDCRAFT_KERNEL` chose the other kernel: it wins over `set_choice`, and scoring
|
||||
/// the wrong kernel against this one's list says nothing.
|
||||
fn global_intersect_scores(backend: solidcraft_smlib::route::Backend) -> Option<Vec<Score>> {
|
||||
use solidcraft_smlib::route::{Backend, choice, set_choice};
|
||||
use solidcraft_smlib::ssi::{Method, intersect};
|
||||
let t0 = std::time::Instant::now();
|
||||
let cases = all_cases();
|
||||
// SMLib serialises its calls, so the cases run one after another; only the scoring is parallel.
|
||||
let outcomes: Vec<Outcome> = cases
|
||||
.iter()
|
||||
.map(|case| {
|
||||
let a = surface(&case.surfaces[0]).unwrap_or_else(|e| panic!("{} surface 1: {e}", case.id));
|
||||
let b = surface(&case.surfaces[1]).unwrap_or_else(|e| panic!("{} surface 2: {e}", case.id));
|
||||
let t = std::time::Instant::now();
|
||||
let r = intersect(&a, &b, Method::Global, 1e-6);
|
||||
runner_output(r, &a, &b, t.elapsed().as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
let outcomes: Vec<Outcome> = {
|
||||
let _kernel = KERNEL_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
set_choice(backend);
|
||||
if choice() != backend {
|
||||
set_choice(Backend::Solidcore);
|
||||
eprintln!("skipped: SOLIDCRAFT_KERNEL chose the other kernel");
|
||||
return None;
|
||||
}
|
||||
// The kernels serialise their calls, so the cases run one after another; only the scoring is parallel.
|
||||
let outcomes = cases
|
||||
.iter()
|
||||
.map(|case| {
|
||||
let a = surface(&case.surfaces[0]).unwrap_or_else(|e| panic!("{} surface 1: {e}", case.id));
|
||||
let b = surface(&case.surfaces[1]).unwrap_or_else(|e| panic!("{} surface 2: {e}", case.id));
|
||||
let t = std::time::Instant::now();
|
||||
let r = intersect(&a, &b, Method::Global, 1e-6);
|
||||
runner_output(r, &a, &b, t.elapsed().as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
set_choice(Backend::Solidcore);
|
||||
outcomes
|
||||
};
|
||||
let ran = t0.elapsed();
|
||||
// Status names for the flags, from the SMLib sources the build used, as score.py has them.
|
||||
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
@@ -626,7 +637,7 @@ fn smlib_global_intersect_at_1e_6() {
|
||||
by_verdict.entry(s.verdict).or_default().push(&s.id);
|
||||
}
|
||||
println!(
|
||||
"SMLib GlobalSurfaceIntersect, approx tol 1e-6 ({} cases, SMLib {:.1} s, total {:.1} s)",
|
||||
"{backend:?} GlobalSurfaceIntersect, approx tol 1e-6 ({} cases, kernel {:.1} s, total {:.1} s)",
|
||||
scores.len(),
|
||||
ran.as_secs_f64(),
|
||||
t0.elapsed().as_secs_f64()
|
||||
@@ -637,7 +648,14 @@ fn smlib_global_intersect_at_1e_6() {
|
||||
for s in scores.iter().filter(|s| s.verdict != Verdict::Pass) {
|
||||
println!(" {:<40} {:<15} {}", s.id, s.verdict.as_str(), s.flags.join("; "));
|
||||
}
|
||||
Some(scores)
|
||||
}
|
||||
|
||||
/// Exactly [`GSI_1E6_PASS`] pass on the C++ kernel.
|
||||
#[cfg(feature = "native")]
|
||||
#[test]
|
||||
fn smlib_global_intersect_at_1e_6() {
|
||||
let Some(scores) = global_intersect_scores(solidcraft_smlib::route::Backend::Smlib) else { return };
|
||||
let passed: std::collections::BTreeSet<&str> = scores.iter().filter(|s| s.verdict == Verdict::Pass).map(|s| s.id.as_str()).collect();
|
||||
let listed: std::collections::BTreeSet<&str> = GSI_1E6_PASS.iter().copied().collect();
|
||||
assert_eq!(listed.len(), GSI_1E6_PASS.len(), "GSI_1E6_PASS lists a case twice");
|
||||
@@ -646,3 +664,16 @@ fn smlib_global_intersect_at_1e_6() {
|
||||
assert!(newly.is_empty() && lost.is_empty(), "now pass (add to GSI_1E6_PASS): {newly:?}; no longer pass: {lost:?}");
|
||||
assert_eq!(passed.len(), 111);
|
||||
}
|
||||
|
||||
/// solidcore passes every case the C++ kernel passes ([`GSI_1E6_PASS`]). The self-crossing
|
||||
/// curves it missed (Viviani's figure eight, the crossing loops) are split at their crossings
|
||||
/// since solidcore's `ssi_split_curves_at_singular_points` (tangency-labs/solidcore#2110).
|
||||
#[test]
|
||||
fn solidcore_global_intersect_at_1e_6() {
|
||||
let Some(scores) = global_intersect_scores(solidcraft_smlib::route::Backend::Solidcore) else { return };
|
||||
let passed: std::collections::BTreeSet<&str> = scores.iter().filter(|s| s.verdict == Verdict::Pass).map(|s| s.id.as_str()).collect();
|
||||
let listed: std::collections::BTreeSet<&str> = GSI_1E6_PASS.iter().copied().collect();
|
||||
let lost: Vec<&str> = listed.difference(&passed).copied().collect();
|
||||
let extra: Vec<_> = passed.difference(&listed).collect();
|
||||
assert!(lost.is_empty(), "solidcore misses what the C++ kernel passes: {lost:?}; extra passes: {extra:?}");
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ use crate::ui_kit;
|
||||
use crate::viewport::NavMode;
|
||||
|
||||
pub const UNITS: &[&str] = &["mm", "cm", "m", "in", "ft"];
|
||||
/// The Geometry kernel preference's choices, as `diagnostics.kernel` spells them.
|
||||
pub const KERNELS: [&str; 2] = ["solidcore", "smlib"];
|
||||
pub const OPENSCAD_NAV: &str = "openscad";
|
||||
pub const TOUCHPAD_NAV: &str = "touchpad";
|
||||
/// A mouse button that can drag the camera.
|
||||
@@ -1159,6 +1161,11 @@ preference_settings! {
|
||||
/// Up/Down and the wheel over a focused value field step its plain number (`value_step`,
|
||||
/// #2320). Off by default: the keys move the text cursor and the wheel scrolls, as before.
|
||||
step_values: bool = false, PAGE_DESIGN => check_id("ui.step_values");
|
||||
/// Which geometry kernel answers every operation (one of [`KERNELS`]): `solidcore`, the
|
||||
/// default, or `smlib`, the C++ kernel. A saved `routed`, the old default, reads as
|
||||
/// `solidcore` ([`migrate`]). Applied through
|
||||
/// `diagnostics.kernel`; `SOLIDCRAFT_KERNEL`, when set, wins.
|
||||
geometry_kernel: String = "solidcore".into(), PAGE_DESIGN;
|
||||
/// When SMLib's full check runs while features are computed (`diagnostics.validation`):
|
||||
/// `each_feature` (the default) checks the bodies each feature keeps once; `each_step`, as
|
||||
/// before, checks after every sweep, boolean and blend; `off` (experimental) never does.
|
||||
@@ -1626,6 +1633,14 @@ pub struct PrefsWindow {
|
||||
/// The Profile operations preference the profiler last followed: applied when it changes,
|
||||
/// so `diagnostics.profile` (an agent) and SOLIDCRAFT_PROFILE can switch it in between.
|
||||
applied_profile: Option<bool>,
|
||||
/// The Geometry kernel preference `diagnostics.kernel` last applied: applied when it changes
|
||||
/// (and at startup), so an agent's `diagnostics.kernel` in between stands.
|
||||
applied_kernel: Option<String>,
|
||||
/// The choice `SOLIDCRAFT_KERNEL` forces when it overrides the preference (the command's
|
||||
/// `environment` and `choice`).
|
||||
pub(crate) kernel_environment: Option<String>,
|
||||
/// The saved kernel is one this build doesn't have (`smlib` without the C++ kernel): the page says so.
|
||||
pub(crate) kernel_unavailable: bool,
|
||||
/// The appearance last asked of the native window ([`sync_window_theme`]).
|
||||
window_theme: Option<egui::SystemTheme>,
|
||||
/// The perspective angle the view was last drawn with ([`follow_view_fov`]); 0 before the first.
|
||||
@@ -2298,6 +2313,26 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
app.prefs_window.applied_profile = Some(profile);
|
||||
}
|
||||
// The geometry kernel, through its command (an unknown saved value is the default). `smlib` on
|
||||
// a build without it is refused: the preference page and the status line say so.
|
||||
let kernel = KERNELS.iter().copied().find(|k| *k == app.preferences.geometry_kernel).unwrap_or(KERNELS[0]);
|
||||
if app.prefs_window.applied_kernel.as_deref() != Some(kernel) {
|
||||
match app.session.execute("diagnostics.kernel", &json!({ "choice": kernel })) {
|
||||
Ok(v) => {
|
||||
let environment = v.get("environment").and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
app.prefs_window.kernel_environment =
|
||||
environment.then(|| v.get("choice").and_then(serde_json::Value::as_str).unwrap_or_default().to_string());
|
||||
app.prefs_window.kernel_unavailable = false;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("geometry kernel preference `{kernel}`: {e}");
|
||||
app.prefs_window.kernel_unavailable = true;
|
||||
let message = app.tr("prefs.geometry_kernel_unavailable").to_string();
|
||||
app.set_status(message, true);
|
||||
}
|
||||
}
|
||||
app.prefs_window.applied_kernel = Some(kernel.into());
|
||||
}
|
||||
apply_file_limits(&app.preferences);
|
||||
// The viewport's display quality (an unknown saved value draws at Normal).
|
||||
solidcraft_engine::doc::display_quality::set_current(
|
||||
@@ -4022,6 +4057,10 @@ pub fn migrate(mut p: Prefs) -> Prefs {
|
||||
p.orbit_around = OrbitAround::Cursor;
|
||||
p.orbit_cursor = false;
|
||||
}
|
||||
// `routed`, the old default, ran every operation on solidcore.
|
||||
if p.geometry_kernel == "routed" {
|
||||
p.geometry_kernel = KERNELS[0].into();
|
||||
}
|
||||
if !crate::space_mouse::drives_each_once(p.space_mouse_axis_source) {
|
||||
log::warn!("preferences: the 3D mouse axis assignment {:?} doesn't drive each axis once; using the default", p.space_mouse_axis_source);
|
||||
p.space_mouse_axis_source = crate::space_mouse::SAME_AXES;
|
||||
@@ -4162,6 +4201,10 @@ fn prepare_preferences(
|
||||
{
|
||||
return Err("`validation` must be each_step, each_feature or off".into());
|
||||
}
|
||||
// Before `migrate`, which reads the old saved `routed` as the default: asked for now, it is refused.
|
||||
if p.get("geometry_kernel").is_some_and(|v| !v.as_str().is_some_and(|k| KERNELS.contains(&k))) {
|
||||
return Err(format!("`geometry_kernel` must be one of {}", KERNELS.join(", ")));
|
||||
}
|
||||
if let Some(value) = p.get("navigation_bar") {
|
||||
crate::navigation_bar::validate(value)?;
|
||||
}
|
||||
@@ -4185,6 +4228,9 @@ fn prepare_preferences(
|
||||
if !UNITS.contains(&new.default_units.as_str()) {
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
}
|
||||
if !KERNELS.contains(&new.geometry_kernel.as_str()) {
|
||||
return Err(format!("`geometry_kernel` must be one of {}", KERNELS.join(", ")));
|
||||
}
|
||||
if !WINDOW_SYSTEMS.contains(&new.window_system.as_str()) {
|
||||
return Err(format!("`window_system` must be one of {}", WINDOW_SYSTEMS.join(", ")));
|
||||
}
|
||||
@@ -4362,6 +4408,61 @@ mod tests {
|
||||
assert!(!solidcraft_engine::profile::enabled());
|
||||
}
|
||||
|
||||
/// The Geometry kernel preference reaches the kernel through diagnostics.kernel, at startup
|
||||
/// and when it changes, and only a choice the command knows is accepted. `smlib` keeps the C++
|
||||
/// kernel reachable, the behaviour from before solidcore was the default. (The kernel choice is
|
||||
/// process-wide and other tests start harnesses meanwhile, so `smlib` is only set on a build
|
||||
/// without it, where it is refused and nothing moves.)
|
||||
#[test]
|
||||
fn the_geometry_kernel_preference_applies_through_its_command() {
|
||||
use serde_json::json;
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.frames(1);
|
||||
assert_eq!(h.app.prefs_window.applied_kernel.as_deref(), Some("solidcore"), "applied at startup");
|
||||
let r = h.app.session.execute("diagnostics.kernel", &json!({})).unwrap();
|
||||
let environment = (r["environment"] == json!(true)).then(|| r["choice"].as_str().unwrap_or_default().to_string());
|
||||
assert_eq!(h.app.prefs_window.kernel_environment, environment);
|
||||
assert!(h.call("ui.prefs", json!({"geometry_kernel": "truck"}))["error"].as_str().is_some_and(|e| e.contains("geometry_kernel")));
|
||||
// `routed` is gone: a saved one reads as solidcore (below), asked for now it is refused.
|
||||
assert!(h.call("ui.prefs", json!({"geometry_kernel": "routed"}))["error"].as_str().is_some_and(|e| e.contains("geometry_kernel")));
|
||||
assert_eq!(h.app.preferences.geometry_kernel, "solidcore");
|
||||
assert_eq!(super::KERNELS, ["solidcore", "smlib"]);
|
||||
h.call("ui.prefs", json!({"geometry_kernel": "solidcore"}));
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.prefs_window.applied_kernel.as_deref(), Some("solidcore"));
|
||||
assert!(!h.app.prefs_window.kernel_unavailable);
|
||||
// Saved with the other preferences.
|
||||
let saved = serde_json::to_value(&h.app.preferences).unwrap();
|
||||
assert_eq!(saved["geometry_kernel"], json!("solidcore"));
|
||||
// On a build without the C++ kernel, asking for it says so: not a switch to the other one.
|
||||
if !solidcraft_engine::kernel::smlib_built() {
|
||||
h.call("ui.prefs", json!({"geometry_kernel": "smlib"}));
|
||||
h.frames(2);
|
||||
assert!(h.app.prefs_window.kernel_unavailable);
|
||||
assert_eq!(h.app.preferences.geometry_kernel, "smlib");
|
||||
assert_ne!(h.app.session.execute("diagnostics.kernel", &json!({})).unwrap()["choice"], json!("smlib"));
|
||||
h.call("ui.prefs", json!({"geometry_kernel": "solidcore"}));
|
||||
h.frames(2);
|
||||
assert!(!h.app.prefs_window.kernel_unavailable);
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved `routed`, the old default, loads as `solidcore`, without an error; any other value
|
||||
/// this version doesn't know stays as it was saved and runs on the default, and nothing panics.
|
||||
#[test]
|
||||
fn a_saved_routed_kernel_loads_as_solidcore() {
|
||||
use serde_json::json;
|
||||
let read = |saved: serde_json::Value| super::migrate(serde_json::from_value::<super::Prefs>(saved).unwrap()).geometry_kernel;
|
||||
assert_eq!(read(json!({"geometry_kernel": "routed"})), "solidcore");
|
||||
assert_eq!(read(json!({})), "solidcore");
|
||||
assert_eq!(read(json!({"geometry_kernel": "smlib"})), "smlib");
|
||||
assert_eq!(read(json!({"geometry_kernel": "truck"})), "truck");
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.geometry_kernel = "truck".into();
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.prefs_window.applied_kernel.as_deref(), Some("solidcore"));
|
||||
}
|
||||
|
||||
/// Preferences saved with a setting this version no longer has still load, every other
|
||||
/// setting kept: the removed key is ignored like any unknown one.
|
||||
#[test]
|
||||
|
||||
@@ -136,4 +136,28 @@ fn origin_plane_names(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
setting::hide_origin(app, ui);
|
||||
ui_kit::text(ui, app.tr("ui.hide_origin_note"), ui_kit::Text::Hint("hide_origin"));
|
||||
});
|
||||
// Which geometry kernel answers; `prefs::apply` applies it through diagnostics.kernel.
|
||||
ui_kit::settings_section(ui, "geometry_kernel", app.tr("prefs.geometry_kernel"), |ui| {
|
||||
// Under SOLIDCRAFT_KERNEL the control shows the environment's choice, disabled.
|
||||
let environment = app.prefs_window.kernel_environment.clone();
|
||||
ui.add_enabled_ui(environment.is_none(), |ui| {
|
||||
let options = ["solidcore", "SMLib"];
|
||||
let shown = environment.as_deref().unwrap_or(&app.preferences.geometry_kernel);
|
||||
let mut selected = KERNELS.iter().position(|k| *k == shown).unwrap_or(0);
|
||||
ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() });
|
||||
if let Some(kernel) = KERNELS.get(selected)
|
||||
&& environment.is_none()
|
||||
&& app.preferences.geometry_kernel != *kernel
|
||||
{
|
||||
app.preferences.geometry_kernel = (*kernel).into();
|
||||
}
|
||||
});
|
||||
ui_kit::text(ui, app.tr("prefs.geometry_kernel_note"), ui_kit::Text::Hint("geometry_kernel"));
|
||||
if environment.is_some() {
|
||||
ui_kit::text(ui, app.tr("prefs.geometry_kernel_environment"), ui_kit::Text::Hint("geometry_kernel_environment"));
|
||||
}
|
||||
if app.prefs_window.kernel_unavailable {
|
||||
ui_kit::text(ui, app.tr("prefs.geometry_kernel_unavailable"), ui_kit::Text::Hint("geometry_kernel_unavailable"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,3 +13,5 @@ allow-wildcard-paths = true
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# solidcore, pinned by rev in Cargo.toml (crates/smlib's second backend).
|
||||
allow-git = ["https://github.com/tangency-labs/solidcore"]
|
||||
|
||||
@@ -164,12 +164,12 @@ same triangles, colour and paint; the picture shown while opening draws its tria
|
||||
`cache/model.json` records `solidcraft-model-cache/8`, the producer revision
|
||||
`solidcraft-truck-adapter/4`, the application version, the build (source commit,
|
||||
CPU architecture and OS), the evaluation source (below, with the CPU architecture
|
||||
and OS), the SHA-256 of the exact `document.json` (whose blob references pin every
|
||||
and OS), the geometry kernel that computed it (`solidcore` or `smlib`), the SHA-256 of the exact `document.json` (whose blob references pin every
|
||||
STEP, mesh and image payload) and of `cache/bodies.bin`, the slices, each kernel
|
||||
body's slice and display share, and the model's records. A cache is read back only
|
||||
when the format, the producer, the evaluation source, the platform and both
|
||||
when the format, the producer, the evaluation source, the platform, the kernel and both
|
||||
digests match: the same source compiled for the same platform computes the same
|
||||
numbers. The application version and the commit are recorded, not compared.
|
||||
numbers on the same kernel; a design opened on the other kernel is evaluated on it. The application version and the commit are recorded, not compared.
|
||||
|
||||
The evaluation source (`SOLIDCRAFT_EVALUATION_SOURCE`, `crates/io/build.rs`) is a
|
||||
digest of the committed source a model is computed from: the `doc` crate and every
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@ What it records, as nested spans on each thread:
|
||||
|
||||
| Category | Spans |
|
||||
|---|---|
|
||||
| `smlib` | every SMLib C API call, timed by the C API itself (`steady_clock`) |
|
||||
| `solidcore` | every call into solidcore, the default kernel |
|
||||
| `smlib` | every SMLib C API call, timed by the C API itself (`steady_clock`), when the C++ kernel is asked for |
|
||||
| `kernel` | booleans, blends, sweeps, lofts, shell/draft/offset, healing, sewing, conversions, meshing, measuring |
|
||||
| `import` | the stages of a STEP or IGES import: parse, index, each solid, placement, mesh check, convert batches |
|
||||
| `feature` | each feature computed, by its type; `doc evaluate` around them |
|
||||
|
||||
@@ -118,6 +118,7 @@ class Real(unittest.TestCase):
|
||||
for area, globs in {"PATHS-ALL": shared, **areas}.items():
|
||||
if area != "PATHS-ALL":
|
||||
self.assertTrue(os.path.isfile(os.path.join(ROOT, plan.CASES, area, "KNOWN_FAILING")), area)
|
||||
self.assertTrue(os.path.isfile(os.path.join(ROOT, plan.CASES, area, "KNOWN_FAILING.solidcore")), area)
|
||||
for g in globs:
|
||||
with self.subTest(area=area, glob=g):
|
||||
rx = plan.glob_regex(g)
|
||||
|
||||
+37
-25
@@ -1,42 +1,54 @@
|
||||
# The cargo tests that fail today, one a line: `<test> <why it fails today>`, with the issue that
|
||||
# tracks it when there is one. Sorted. `<test>` is nextest's `<binary id>::<test name>`, as the
|
||||
# junit report names it. CI's test jobs (`cargo xtask known-failing`) fail on a test that fails
|
||||
# and is not listed here, and on a listed test that passes: a fix removes its line in the same
|
||||
# pull request. The tests stay un-ignored, so every run shows whether they still fail.
|
||||
# Refresh (after an SMLib pin bump): download the junit artifacts of develop's latest CI run, one
|
||||
# directory each (`gh run download <run> -R tangency-labs/solidcraft -n <artifact> -D <dir>/<artifact>`
|
||||
# for junit and junit-gpu-1..4), run `cargo xtask known-failing --junit <dir>`, then drop each
|
||||
# FIXED line and add each REGRESSION line with its cause, from the test's failure message.
|
||||
# Baseline: develop 2bf58241f (CI run 41522001173, x86_64 Linux), after the SMLib a18bbe5 pin (v1.1.0-alpha).
|
||||
solidcraft-cli::action_matrix::action_matrix_sample 2 baseline actions fail on SMLib: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box, shelled again open at an inner wall)
|
||||
solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, SMLib thicken status 1001
|
||||
# The cargo tests that fail today on the default kernel, solidcore, one a line: `<test> <why it fails
|
||||
# today>`, with the issue that tracks it when there is one. Sorted. `<test>` is nextest's
|
||||
# `<binary id>::<test name>`, as the junit report names it. CI's test jobs (`cargo xtask known-failing`)
|
||||
# fail on a test that fails and is not listed here, and on a listed test that passes: a fix removes its
|
||||
# line in the same pull request. The tests stay un-ignored, so every run shows whether they still fail.
|
||||
# `KNOWN_FAILING_TESTS.smlib` is the same list for the C++ kernel (SOLIDCRAFT_KERNEL=smlib, graded with
|
||||
# --list). Refresh as that list's header says.
|
||||
solidcraft-cli::action_matrix::action_matrix_sample 3 baseline actions fail, the same 3 as on C++: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box), 1 fillet (SmSurfOfRevolution Test #7 on oblique_cut_of_cylinder)
|
||||
solidcraft-cli::model_cache::a_later_round_after_opening_blends_the_saved_rounds_again the later r3 round at the r2 mitre builds solidcore's 10-face corner (7928.743041), the test wants SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310: 15 faces, 7928.142563938
|
||||
solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, thicken status 1001, as on C++
|
||||
solidcraft-doc::eval::chain_tests::a_propagated_chain_that_cannot_build_says_how_to_blend_the_edge_alone the propagated chain now builds a round (9 faces), so there is no refusal to read, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-doc::eval::cost_tests::a_cut_leaves_bodies_it_cannot_reach_as_they_were main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-doc::eval::cost_tests::a_tap_marking_searches_only_the_bodies_near_it main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-doc::eval::cost_tests::a_tap_marking_still_searches_the_bodies_within_its_reach main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own SMLib meshes a box's planar faces into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12)
|
||||
solidcraft-engine::cmd::sketch_project::tests::intersection_curve_of_picked_faces_spans_the_whole_surface main's mesh-based version (#9107) not taken; the exact replacement is to come
|
||||
solidcraft-engine::cmd::edit::tests::inserting_variable_fillet_control_preserves_parameters_at_existing_positions solidcore refuses a variable fillet whose law has a corner (solidcore#430)
|
||||
solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own the box's planar faces are meshed into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12), as on SMLib
|
||||
solidcraft-engine::cmd::sketch_project::tests::intersection_curve_of_picked_faces_spans_the_whole_surface its premise fails off truck: the fit-point spline wall's 3 span faces are not one surface patch in the kernel's mesh (surface_patch gives 1 face, the test wants 3), as on SMLib
|
||||
solidcraft-engine::extent_tests::cut_to_the_far_side_of_a_curved_wall main's mesh-based version (#8634) not taken; the exact replacement is to come
|
||||
solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; SMLib parts the plate there into 2 bodies, the test wants truck's 1
|
||||
solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radius SMLib's round at the foot of a boss is not an analytic torus of minor radius 3, so no face of the body is (the test finds none to measure)
|
||||
solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point SMLib fillet status 1001 on the taper fit (radius 16 closing to 0)
|
||||
solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once on SMLib a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh evaluation; the surface is the same. Dealt with in solidcore, where the mesher moves (docs/layers.md)
|
||||
solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; the kernel parts the plate there into 2 bodies, the test wants truck's 1, as on SMLib
|
||||
solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radius the round at the foot of a boss is not an analytic torus of minor radius 3, so no face of the body is (the test finds none to measure), as on SMLib
|
||||
solidcraft-engine::tests::a_later_round_at_a_mitred_corner_is_blended_with_the_rounds_there solidcore's corner of three rounds of different radii is 7928.74304145614 with 10 faces; the test wants SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310: 15 faces, 7928.142563938
|
||||
solidcraft-engine::tests::chord_and_variable_fillets its 2 → 4 → 2 waist: solidcore refuses a variable fillet whose law has a corner (solidcore#430)
|
||||
solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point fillet status 1001 on the taper fit (radius 16 closing to 0), as on C++
|
||||
solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh mesh of it, where truck's meshes are the same under a rigid move, as on SMLib
|
||||
solidcraft-engine::tests::robustness::fillet_into_an_earlier_round R2 into the earlier round: volume 35923.387 against 35919.524, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together SMLib fillet status 1001 on the round that ends in the earlier one
|
||||
solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x a picked edge runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81)
|
||||
solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together fillet status 1001 on the round that ends in the earlier one, as on C++
|
||||
solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x the arc case's rounds remove 72.783 (scaled back) against 72.713 at 1x, as on C++
|
||||
solidcraft-kernel::blend_corner::tests::a_round_next_to_a_round_of_another_radius the face count is 9, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds SMLib's check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01 (#2422)
|
||||
solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds the check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01, as on C++ (#2422)
|
||||
solidcraft-kernel::blend_corner::tests::three_rounds_at_a_corner the corner's variable-radius transition between rounds of different radii meets its neighbours at 0.144 degrees once its sections are the balls' touch circles (fillet_gn_section_generator, solidcore#2262; 0 with the great circles), as on SMLib d01d7b0; the corner step replaces that transition
|
||||
solidcraft-kernel::blend_smlib::tests::smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why the smooth edge builds (volume 7957.723) where a refusal is expected, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::blend_variable::tests::variable_radius_with_a_corner_through_a_middle_point solidcore refuses a variable fillet whose law has a corner (solidcore#430)
|
||||
solidcraft-kernel::corner_g2::tests::a_concave_corner_takes_the_g2_patch_too the concave G2 corner's curvature jump clear of the corners is 5.5e-2, above the bound, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges 2.6e-6 to 1.33e-3 past the box (the explicit corner is in progress)
|
||||
solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner loop's fill misses its neighbours' curvature by 1.6e-3 (tolerance 1e-3), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges past the box, as on C++
|
||||
solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner between curvature rounds jumps 2.07 in curvature over 538 stations on solidcore; the test wants it curvature continuous, as SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 builds it
|
||||
solidcraft-kernel::corner_g2::tests::set_back_8_the_g2_corner_lies_between_the_hull_and_the_prisms set back 8: the G2 corner's distance check reads 0.0611, past its bound (volume 47370.642, in range), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, which SMLib's exact tight box now sees (the explicit G2 corner builds it inside)
|
||||
solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, as on C++
|
||||
solidcraft-kernel::fillet_setback_tests::a_setback_not_past_the_radius_is_refused_naming_the_least solidcore builds setback 5.5 (15 faces) where SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 refuses it naming the least that builds; solidcraft_smlib::blend_fit names no size on solidcore until then
|
||||
solidcraft-kernel::issue_851_tests::blend_the_arc_top_edge_and_the_top_front_edge removes 59.148 against 60.122, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::issue_851_tests::fillet_the_arc_top_edge_and_the_front_upright_at_the_arc removes 31.497 against 32.310, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_left_upright removes 42.236 against 43.187, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_upright_at_the_arc removes 41.341 against 42.146, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::smooth_edges::tests::a_seam_split_in_two_edges_is_drawn_whole the side seam is one edge ([24]), not split in two, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the partial selection runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81)
|
||||
solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the R25 rim and a corner arc round together since solidcore#2223, but the two R2 corner arcs picked together fail 1001 in trim_at_creases (cliff_overflow with the crease trims off), though each builds alone (solidcore#2314)
|
||||
solidcraft-kernel::tests::fillet_three_edges_at_a_corner solidcore builds the corner as an analytic sphere octant (sphere: 1); since 38385ab5d the test wants SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273: one rational patch on the ball's sphere
|
||||
solidcraft-smlib::core::blend_tests::fillet_all_edges::solidcore_matches_the_cpp_kernel the C++ comparison: solidcore's 8 box corners are analytic spheres, C++ 5e8c9c1's rational splines (SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273)
|
||||
solidcraft-smlib::core::blend_tests::fillet_variable_linear::solidcore_matches_the_cpp_kernel the C++ comparison: solidcore 5972.141093 against C++ 5e8c9c1's 5972.142573 (SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273)
|
||||
solidcraft-smlib::core::blend_tests::rounds_on_the_871_part the C++ comparison: solidcore 3874.348250 against C++ 5e8c9c1's 3874.348458 (SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273)
|
||||
solidcraft-smlib::core::blend_tests::setback_corner::solidcore_matches_the_cpp_kernel the C++ comparison: solidcore 5943.191647 against C++ f916a8d's 5948.231174 (SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310)
|
||||
solidcraft-smlib::core::convert_tests::cpp_round_trip::a_shelled_rounded_box the C++ round trip: the shelled rounded box C++ 5e8c9c1 builds differs from solidcore's in its arrays (real array 154[0]: -10 against 0), its corners built by SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273
|
||||
solidcraft-smlib::core::offset_tests::mitred_rounds_follow_a_moved_top the C++ comparison: solidcore's offset edges keep their tolerances (offset_edges_keep_source_tolerance, solidcore#2111), C++ at 7ac037e's do not, so the moved tops' volumes are 2e-9 to 2.3e-7 apart; both are 1.2e-8 to 3.1e-7 off the closed form at accuracy 1e-8, solidcore with the fix 2.1e-7 at most
|
||||
solidcraft-smlib::ops::tests::a_setback_corner_left_open_returns_its_hole the open corner returns holes of 9, 27 and 19 edges, not its one hole; SMLib's open_holes counts a seam edge its own face uses twice as free (fixed for v1.1.0-alpha.1)
|
||||
solidcraft-smlib::ops::tests::a_variable_round_with_a_corner_in_its_law_is_its_balls_union solidcore refuses a variable fillet whose law has a corner (solidcore#430)
|
||||
solidcraft-smlib::ops::tests::fillet_takes_exactly_the_picked_edges one round on the picked edge gives 9 faces, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# The cargo tests that fail today on the C++ kernel (SOLIDCRAFT_KERNEL=smlib), one a line: `<test> <why it fails today>`, with the issue that
|
||||
# tracks it when there is one. Sorted. `<test>` is nextest's `<binary id>::<test name>`, as the
|
||||
# junit report names it. CI's test jobs (`cargo xtask known-failing`) fail on a test that fails
|
||||
# and is not listed here, and on a listed test that passes: a fix removes its line in the same
|
||||
# pull request. The tests stay un-ignored, so every run shows whether they still fail.
|
||||
# Refresh (after an SMLib pin bump): download the junit artifacts of develop's latest CI run, one
|
||||
# directory each (`gh run download <run> -R tangency-labs/solidcraft -n <artifact> -D <dir>/<artifact>`
|
||||
# for junit and junit-gpu-1..4), run `cargo xtask known-failing --junit <dir>`, then drop each
|
||||
# FIXED line and add each REGRESSION line with its cause, from the test's failure message.
|
||||
# Baseline: develop 38fd4a4f6 (CI run 41157648345, x86_64 Linux), after the SMLib eaa998a pin; SMLib lines as of f916a8d (smlib 5862ee223).
|
||||
solidcraft-cli::action_matrix::action_matrix_sample 2 baseline actions fail on SMLib: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box, shelled again open at an inner wall)
|
||||
solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, SMLib thicken status 1001
|
||||
solidcraft-doc::eval::chain_tests::a_propagated_chain_that_cannot_build_says_how_to_blend_the_edge_alone the propagated chain now builds a round (9 faces), so there is no refusal to read, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-doc::eval::cost_tests::a_cut_leaves_bodies_it_cannot_reach_as_they_were main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-doc::eval::cost_tests::a_tap_marking_searches_only_the_bodies_near_it main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-doc::eval::cost_tests::a_tap_marking_still_searches_the_bodies_within_its_reach main's mesh-based version (#10303) not taken; the exact replacement is to come
|
||||
solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own SMLib meshes a box's planar faces into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12)
|
||||
solidcraft-engine::cmd::sketch_project::tests::intersection_curve_of_picked_faces_spans_the_whole_surface main's mesh-based version (#9107) not taken; the exact replacement is to come
|
||||
solidcraft-engine::extent_tests::cut_to_the_far_side_of_a_curved_wall main's mesh-based version (#8634) not taken; the exact replacement is to come
|
||||
solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; SMLib parts the plate there into 2 bodies, the test wants truck's 1
|
||||
solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radius SMLib's round at the foot of a boss is not an analytic torus of minor radius 3, so no face of the body is (the test finds none to measure)
|
||||
solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point SMLib fillet status 1001 on the taper fit (radius 16 closing to 0)
|
||||
solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once on SMLib a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh evaluation; the surface is the same. Dealt with in solidcore, where the mesher moves (docs/layers.md)
|
||||
solidcraft-engine::tests::robustness::fillet_into_an_earlier_round R2 into the earlier round: volume 35923.387 against 35919.524, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-engine::tests::shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls SMLib shell status 1034 on a box with all 12 edges rounded (Ball corners since 5e8c9c1), opened at one face (tangency-labs/smlib#102)
|
||||
solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together SMLib fillet status 1001 on the round that ends in the earlier one
|
||||
solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x a picked edge runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81)
|
||||
solidcraft-kernel::blend_corner::tests::a_round_next_to_a_round_of_another_radius the face count is 9, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds SMLib's check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01 (#2422)
|
||||
solidcraft-kernel::blend_smlib::tests::smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why the smooth edge builds (volume 7957.723) where a refusal is expected, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::a_concave_corner_takes_the_g2_patch_too the concave G2 corner's curvature jump clear of the corners is 5.5e-2, above the bound, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges 2.6e-6 to 1.33e-3 past the box (the explicit corner is in progress)
|
||||
solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner loop's fill misses its neighbours' curvature by 1.6e-3 (tolerance 1e-3), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::set_back_8_the_g2_corner_lies_between_the_hull_and_the_prisms set back 8: the G2 corner's distance check reads 0.0611, past its bound (volume 47370.642, in range), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, which SMLib's exact tight box now sees (the explicit G2 corner builds it inside)
|
||||
solidcraft-kernel::issue_851_tests::blend_the_arc_top_edge_and_the_top_front_edge removes 59.148 against 60.122, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::issue_851_tests::fillet_the_arc_top_edge_and_the_front_upright_at_the_arc removes 31.497 against 32.310, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_left_upright removes 42.236 against 43.187, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_upright_at_the_arc removes 41.341 against 42.146, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::smooth_edges::tests::a_seam_split_in_two_edges_is_drawn_whole the side seam is one edge ([24]), not split in two, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the partial selection runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81)
|
||||
solidcraft-smlib::ops::tests::a_setback_corner_left_open_returns_its_hole the open corner returns holes of 9, 27 and 19 edges, not its one hole; SMLib's open_holes counts a seam edge its own face uses twice as free (fixed for v1.1.0-alpha.1)
|
||||
solidcraft-smlib::ops::tests::fillet_takes_exactly_the_picked_edges one round on the picked edge gives 9 faces, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established
|
||||
@@ -1,17 +1,22 @@
|
||||
# Kernel regression rig
|
||||
|
||||
Every geometry case the old kernel (truck) got wrong, recorded in an issue, a pull request or a
|
||||
crash report, is a use case here with its own right answer. The kernel, SMLib, is held to all of
|
||||
them. `cargo xtask kernel-cases` runs them and writes [`docs/kernel-cases.md`](../../docs/kernel-cases.md).
|
||||
crash report, is a use case here with its own right answer. The kernel, solidcore, is held to all
|
||||
of them, as is the C++ SMLib kernel it was ported from when asked for. `cargo xtask kernel-cases`
|
||||
runs them on solidcore; `--kernel smlib` runs them on C++ and writes
|
||||
[`docs/kernel-cases.md`](../../docs/kernel-cases.md).
|
||||
|
||||
```sh
|
||||
cargo xtask kernel-cases # every case
|
||||
cargo xtask kernel-cases # every case, on solidcore
|
||||
cargo xtask kernel-cases --cli target/release/solidcraft-cli --out /tmp/run # with a CLI already built
|
||||
cargo run -p solidcraft-cli --features smlib -- kernel-cases tests/kernel-cases/booleans/<id> # one case
|
||||
cargo xtask kernel-cases --kernel both --cli target/release/solidcraft-cli # SMLib and solidcore side by side
|
||||
cargo run -p solidcraft-cli -- kernel-cases tests/kernel-cases/booleans/<id> # one case
|
||||
```
|
||||
|
||||
The CLI links SMLib by default (`scripts/fetch-smlib.sh` fetches the prebuilt library first);
|
||||
built with `--no-default-features`, every operation answers that SMLib is not built. The run's output folder (`--out`, default `target/kernel-cases`)
|
||||
The CLI builds with solidcore alone by default. The C++ SMLib kernel is opt-in at build time:
|
||||
`--features smlib` links it (`scripts/fetch-smlib.sh` fetches the prebuilt library first), and
|
||||
`--kernel smlib` or `both` without `--cli` builds the CLI that way. On a build without it, asking
|
||||
for SMLib answers that this build doesn't include it. The run's output folder (`--out`, default `target/kernel-cases`)
|
||||
gets `results.json`, every case's full result, and `dumps/<area>-<case>/`: the inputs of each
|
||||
SMLib call a case failed in, for replay (the CLI's `--dumps=DIR`, which the rig always passes).
|
||||
`--validation MODE` runs the cases' features under that mode of SMLib's check
|
||||
@@ -19,6 +24,13 @@ SMLib call a case failed in, for replay (the CLI's `--dumps=DIR`, which the rig
|
||||
once), `each_step` (after every sweep, boolean and blend, as before) or `off`; the
|
||||
`KNOWN_FAILING` lists are each_feature's.
|
||||
|
||||
`--kernel solidcore|smlib|both` (default `solidcore`) picks the backend each case's process runs on,
|
||||
through `SOLIDCRAFT_KERNEL`. A `solidcore` run keeps its `results.json`, `dumps/` and `report.md`
|
||||
in `<out>/solidcore/` and leaves `docs/kernel-cases.md`, which is the `smlib` run's, alone. `both` runs every case on each
|
||||
backend, keeps `docs/kernel-cases.md` as the SMLib report and writes the two side by side to
|
||||
[`docs/kernel-cases-backends.md`](../../docs/kernel-cases-backends.md) (cases that differ first)
|
||||
and `<out>/backends.json`.
|
||||
|
||||
## Rules
|
||||
|
||||
- **The answer never comes from a kernel.** Not from truck's output, nor any commercial
|
||||
@@ -126,17 +138,19 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage
|
||||
Every area is a regression suite: each case is one small case of one issue, with its exact
|
||||
answer. `.github/workflows/kernel-rig.yml` runs them, and the ladder, on pull requests (not
|
||||
drafts). A plan job (`scripts/ci/kernel_rig_plan.py`) picks the areas from the changed files, one
|
||||
job builds `solidcraft-cli` (release, SMLib) and the rig's runner, and a matrix of shards, one per
|
||||
job builds `solidcraft-cli` (release, `--features smlib`, for the ladder's `--kernel smlib`) and the rig's runner, and a matrix of shards, one per
|
||||
selected area plus the ladder, runs them in parallel; the `kernel-rig` check passes when every
|
||||
selected shard does (and when none was selected). Pushes to main and develop, merge groups, the
|
||||
release gate (`release-gate.yml`) and a run by hand run every area. Each area's shard runs:
|
||||
|
||||
```sh
|
||||
cargo xtask kernel-cases --area <area> --cli target/release/solidcraft-cli \
|
||||
--known-failing tests/kernel-cases/<area>/KNOWN_FAILING
|
||||
--known-failing tests/kernel-cases/<area>/KNOWN_FAILING.solidcore
|
||||
```
|
||||
|
||||
`<area>/KNOWN_FAILING` lists the cases of that area that fail today, sorted, one a line with its
|
||||
The gate grades the kernel the run is on, so a list is one kernel's: `<area>/KNOWN_FAILING.solidcore`
|
||||
for the default, `<area>/KNOWN_FAILING` with `--kernel smlib` (`--kernel both` is refused). Each lists
|
||||
the cases of that area that fail today on it, sorted, one a line with its
|
||||
cause (`<case> <why it fails today>`; `#` starts a comment; the header names the baseline: the
|
||||
develop commit and SMLib revision it was taken from). The gate fails on a **regression**, a
|
||||
case that fails (or crashes, hangs, or is not supported yet) and is not listed, and on a
|
||||
@@ -208,11 +222,15 @@ running part grows one tier at a time through `base`), so the last case carries
|
||||
`method` gives the derivation).
|
||||
|
||||
```sh
|
||||
cargo xtask kernel-cases --ladder --cli target/release/solidcraft-cli
|
||||
cargo xtask kernel-cases --ladder --cli target/release/solidcraft-cli --gate
|
||||
cargo xtask kernel-cases --ladder --cli target/release/solidcraft-cli --record "why"
|
||||
cargo xtask kernel-cases --ladder --kernel smlib --cli target/release/solidcraft-cli
|
||||
cargo xtask kernel-cases --ladder --kernel smlib --cli target/release/solidcraft-cli --gate
|
||||
cargo xtask kernel-cases --ladder --kernel smlib --cli target/release/solidcraft-cli --record "why"
|
||||
```
|
||||
|
||||
The golden is the C++ kernel's, so these say `--kernel smlib`. Without it a run is on solidcore and is
|
||||
graded against the same golden; today that differs on one case,
|
||||
`ladder/09-shells-and-offsets/010-shell-under-rounded-top`.
|
||||
|
||||
The ladder needs no known-failing list: the golden is the reference. Its shard in the kernel rig
|
||||
gate runs with `--gate`, which fails on any case whose status or counts differ from the golden,
|
||||
better or worse, and on a case with no golden; times are not compared there (they are the
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# The assemblies cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The booleans cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The construction cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The continuity cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The crashes cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,37 @@
|
||||
# The fillets cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
1080-fillet-equal-slope-basin-1-mm-rim-loop-4 [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam
|
||||
1080-fillet-sloped-basin-1-mm-rim-r4-2-edges [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam
|
||||
1080-fillet-sloped-basin-1-mm-rim-r4-4-edges [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam
|
||||
1207-fillet-bar-top-edge-after-boss-arc-round-r0-5 [fail] step 4 (solid.fillet): body `Arm`: the operation failed: SMLib fillet: SMLib's check refuses the result: 5 failed tests: SmBSplineCurve::Test #15 Bad Repeated CPts: BSplineCurve has Internal Coincident ControlPoints (causes
|
||||
2893-chamfer-distance-and-angle-past-the-wall-refused [fail] refused, but not because "angle": step 2 (solid.chamfer): SMLib chamfer: operation: SMLib rejected the input (SM_ERR_INVALID_INPUT)
|
||||
2893-chamfer-two-distances-elliptical-rim [fail] refused, but not because "not supported yet": step 3 (solid.chamfer): SMLib chamfer: operation: SMLib failed (status 1001) (tangent propagation blended the picked edges' tangent chain with them; with it off, tangent_chain:
|
||||
4782-fillet-bar-edge-wider-than-both-faces [fail] continuity_order: got Box: no null seams want >= g1 at every blend seam
|
||||
5158-fillet-round-just-past-a-10mm-face [fail] step 2 (solid.fillet): SMLib fillet: operation: SMLib failed (status 1001)
|
||||
851-fillet-arc-then-top-front [fail] fillet status 1001, as on SMLib 5e8c9c1 (see KNOWN_FAILING): with tangent_chain true since 21f00a917 the top front edge's chain takes the arc round's end curve, where the R2 ball sits on that round's centre circle (a torus of tube-centre radius 0, the three-edge rolling-ball corner)
|
||||
851-fillet-top-front-then-upright [fail] fillet status 1001, as on SMLib 5e8c9c1 (see KNOWN_FAILING): with tangent_chain true since 21f00a917 the upright's chain takes the first round's end curve, where the R2 ball sits on that round's axis (a torus of tube-centre radius 0, the three-edge rolling-ball corner)
|
||||
9-fillet-corner-curvature-with-reduce-fit [unsupported] solid.fillet builds `corner: g2` with `fit: exact` only
|
||||
9-fillet-corner-curvature-with-taper-fit [unsupported] solid.fillet builds `corner: g2` with `fit: exact` only
|
||||
cov-fillet-g2-corner-beside-a-cylinder [fail] step 6 (solid.fillet): the open setback corner is not a closed solid, as on SMLib d01d7b0
|
||||
cov-fillet-g2-five-edge-apex-corner [fail] step 12 (solid.fillet): invalid input: the setback must be more than 3.5056 mm at the corner (-0.000, 0.000, 30.000): the rounds' contact lines meet 3
|
||||
cov-fillet-g2-four-edge-apex-corner [fail] step 11 (solid.fillet): invalid input: the setback must be more than 3.9027 mm at the corner (0.000, 0.000, 30.000): the rounds' contact lines meet 3.
|
||||
cov-fillet-g2-mixed-corner [unsupported] refused as on SMLib d01d7b0: a G2 corner is set back only where every edge meeting it is rounded and all are convex or all concave
|
||||
cov-fillet-g2-oblique-three-edge-corner [unsupported] refused on solidcore: its three edges meet at different wedge angles, and one curvature fillet takes the first edge's equal-area thumbweight for every edge (solidcore#2250, issuecomment-6563419897); SMLib d01d7b0 fails it too (see KNOWN_FAILING)
|
||||
cov-fillet-g2-pocket-corner [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING)
|
||||
cov-fillet-section-curvature-pipe-on-plate-r2 [fail] solidcore builds the order-2 section as SMLib d01d7b0 did (40302.5908, G2 on both seams); the case now grades SMLib 5e8c9c1's section, the round as its definition, G3 on the pipe and 40307.8856 (fork 4a10ed8 and 79db628, to be ported in solidcore#2273)
|
||||
cov-fillet-turn-swept-boss-base-r1 [fail] as on SMLib: solid.fillet has no `turn`; the corners are mitred (12508.9675, the Mitre case's volume)
|
||||
cov-fillet-turn-swept-pocket-sharp-corner-refused-r1 [fail] as on SMLib: solid.fillet has no `turn`, so the sharp concave corners are mitred, not refused
|
||||
cov-junction-boss-tilt-20-root-round [fail] fillet status 1001 on a cylinder tilted 20 degrees into the plate, as on SMLib
|
||||
cov-junction-boss-tilt-40-root-round [fail] fillet status 1001 on a cylinder tilted 40 degrees into the plate, as on SMLib
|
||||
cov-junction-tee-equal-round [fail] the result is not a closed solid, as on SMLib: equal pipes' intersection is two plane ellipses crossing, a corner the old corner code cannot join (the corner step)
|
||||
cov-junction-y-junction-120-round [unsupported] refused as on SMLib: the faces between the three intersection curves narrow to nothing at the two triple points, so the room check trips (the corner step)
|
||||
report-drafted-peg-front-and-concave-rounds-together-r2 [unsupported] step 13 (solid.fillet): invalid input: not supported yet: the fillet meets a corner of this kind, which SMLib's blends do not handle yet (where its ed
|
||||
report-fillet-concave-arc-and-front-top-edge-together-r5-solver-residues [fail] bbox.min: got [0.4892940775280783, 0.05636444090890084, 0.0] want [0.4892940775280783, 0.05792545052986289, 0.0]
|
||||
report-fillet-concave-arc-and-front-top-edge-together-r7-solver-residues [fail] bbox.min: got [0.4892940775169876, 0.02441657049978923, 0.0] want [0.4892940775280783, 0.05792545052986289, 0.0]
|
||||
report-fillet-curvature-three-edges-at-bar-corner [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING)
|
||||
report-fillet-curvature-three-edges-at-bar-corner-solver-residues [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING)
|
||||
report-peg-front-and-concave-chamfers-together-d2 [fail] step 13 (solid.chamfer): SMLib chamfer: operation: SMLib failed (status 1001)
|
||||
report-peg-front-and-concave-rounds-together-r2 [fail] continuity_order: got {'body': 'Peg', 'edges': 'blend', 'order': 'g0', ' want >= g1 at every blend seam
|
||||
report-peg-front-and-concave-rounds-together-r5 [fail] continuity_order: got {'body': 'Peg', 'edges': 'blend', 'order': 'g0', ' want >= g1 at every blend seam
|
||||
report-peg-step-front-and-concave-rounds-together-r2 [fail] continuity_order: got {'body': 'Peg', 'edges': 'blend', 'order': 'g0', ' want >= g1 at every blend seam
|
||||
@@ -0,0 +1,3 @@
|
||||
# The frames cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The history cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The import cases that fail today on solidcore, one a line: `<case> <why it fails today>`. Sorted.
|
||||
# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes.
|
||||
# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user