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:
jyviko
2026-11-02 16:42:23 -05:00
114 changed files with 18649 additions and 585 deletions
+73 -36
View File
@@ -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
-3
View File
@@ -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"
-3
View File
@@ -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
+5 -8
View File
@@ -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
-3
View File
@@ -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 -5
View File
@@ -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.
-3
View File
@@ -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"
-15
View File
@@ -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" \
+10
View File
@@ -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
View File
@@ -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",
+5
View File
@@ -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" }
+4 -3
View File
@@ -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.
+5 -2
View File
@@ -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
+41
View File
@@ -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();
}
+4 -4
View File
@@ -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.
+9
View File
@@ -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");
}
}
+4 -3
View File
@@ -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 上运行。"
}
}
+1 -1
View File
@@ -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.
+30 -1
View File
@@ -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"))
}
+2 -3
View File
@@ -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}");
}
+1 -1
View File
@@ -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]
+17 -1
View File
@@ -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);
+3 -3
View File
@@ -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
View File
@@ -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]
+12 -3
View File
@@ -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")))
}
+3 -3
View File
@@ -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]
+1
View File
@@ -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
+2
View File
@@ -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::{
-25
View File
@@ -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 {
+1 -2
View File
@@ -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)
}
+4 -3
View File
@@ -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:?}");
}
}
}
+9 -3
View File
@@ -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}");
+17 -2
View File
@@ -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
+41 -8
View File
@@ -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;
+5 -13
View File
@@ -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}")))
}
+5 -2
View File
@@ -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).
+7 -3
View File
@@ -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()));
+569
View File
@@ -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
+243
View File
@@ -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)),
}
}
+728
View File
@@ -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
+752
View File
@@ -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[&REGION_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);
}
}
+132
View File
@@ -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())
}
+60
View File
@@ -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))
}
+330
View File
@@ -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 })
})
}
+354
View File
@@ -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 })
}
+511
View File
@@ -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[&REGION_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:?}");
}
}
+178
View File
@@ -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)
})
}
+457
View File
@@ -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());
}
}
+660
View File
@@ -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)
})
}
+909
View File
@@ -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, &notch, 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, &notch, 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");
}
}
+513
View File
@@ -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(&copy, 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))))
}
+429
View File
@@ -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);
}
+121
View File
@@ -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
+768
View File
@@ -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");
}
+32
View File
@@ -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)),
})
})
}
+328
View File
@@ -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:?}");
}
}
}
+862
View File
@@ -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, &copy_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()
})
}
+429
View File
@@ -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
View File
@@ -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(&center, radius).map(Brep).map_err(error)
ffi::make_sphere(&center, 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(&center, major_radius, minor_radius).map(Brep).map_err(error)
ffi::make_torus(&center, 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 }
+10
View File
@@ -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
+7 -3
View File
@@ -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)
}
}
+47 -5
View File
@@ -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());
+10 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+2 -1
View File
@@ -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());
+1 -1
View File
@@ -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)?;
+171
View File
@@ -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);
}
}
+9 -3
View File
@@ -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)
}
}
+20 -9
View File
@@ -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
View File
@@ -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(&center, &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(&center, &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
View File
@@ -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(&degree) {
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
View File
@@ -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();
+14 -5
View File
@@ -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)
}
}
+52 -21
View File
@@ -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:?}");
}
+101
View File
@@ -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]
+24
View File
@@ -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"));
}
});
}
+2
View File
@@ -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"]
+3 -3
View File
@@ -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
View File
@@ -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 |
+1
View File
@@ -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
View File
@@ -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
+43
View File
@@ -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
+30 -12
View File
@@ -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