diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1e6eb58063..513596eade 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index cf1c396915..0e7d17e09c 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -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"
diff --git a/.github/workflows/external-corpus.yml b/.github/workflows/external-corpus.yml
index 8267eff1f6..f43515a607 100644
--- a/.github/workflows/external-corpus.yml
+++ b/.github/workflows/external-corpus.yml
@@ -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
diff --git a/.github/workflows/fillet-rig.yml b/.github/workflows/fillet-rig.yml
index 57098f1134..cb02ca966b 100644
--- a/.github/workflows/fillet-rig.yml
+++ b/.github/workflows/fillet-rig.yml
@@ -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
diff --git a/.github/workflows/generated.yml b/.github/workflows/generated.yml
index abe627d32b..b70b1338a8 100644
--- a/.github/workflows/generated.yml
+++ b/.github/workflows/generated.yml
@@ -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
diff --git a/.github/workflows/kernel-rig.yml b/.github/workflows/kernel-rig.yml
index ba811a7fb6..4b1c48e13c 100644
--- a/.github/workflows/kernel-rig.yml
+++ b/.github/workflows/kernel-rig.yml
@@ -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/ /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/ /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.
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index ea87218cd3..02c2c02002 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -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"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5882ba943a..007de0fab8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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" \
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index a54b0cc887..611d551b9c 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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
diff --git a/Cargo.lock b/Cargo.lock
index 3087b7a66d..ef5c9e582c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/Cargo.toml b/Cargo.toml
index f6566eb5fd..d35d6674f1 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
diff --git a/apps/solidcraft-cli/Cargo.toml b/apps/solidcraft-cli/Cargo.toml
index 258dd32f91..ed69dc559e 100644
--- a/apps/solidcraft-cli/Cargo.toml
+++ b/apps/solidcraft-cli/Cargo.toml
@@ -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.
diff --git a/apps/solidcraft-cli/src/kernel_cases.rs b/apps/solidcraft-cli/src/kernel_cases.rs
index dbe6e5343c..e1c9247525 100644
--- a/apps/solidcraft-cli/src/kernel_cases.rs
+++ b/apps/solidcraft-cli/src/kernel_cases.rs
@@ -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
diff --git a/apps/solidcraft-cli/tests/model_cache.rs b/apps/solidcraft-cli/tests/model_cache.rs
index ff94461d90..91011e67c6 100644
--- a/apps/solidcraft-cli/tests/model_cache.rs
+++ b/apps/solidcraft-cli/tests/model_cache.rs
@@ -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 = 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();
+}
diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml
index 7a7661a88e..57b7c539f9 100644
--- a/apps/solidcraft-web/Cargo.toml
+++ b/apps/solidcraft-web/Cargo.toml
@@ -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.
diff --git a/apps/solidcraft-web/build.rs b/apps/solidcraft-web/build.rs
new file mode 100644
index 0000000000..a66d6cc007
--- /dev/null
+++ b/apps/solidcraft-web/build.rs
@@ -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");
+ }
+}
diff --git a/apps/solidcraft/Cargo.toml b/apps/solidcraft/Cargo.toml
index 5e3bfc360b..6860706a12 100644
--- a/apps/solidcraft/Cargo.toml
+++ b/apps/solidcraft/Cargo.toml
@@ -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.
diff --git a/assets/locales/pending/geometry-kernel.json b/assets/locales/pending/geometry-kernel.json
new file mode 100644
index 0000000000..40cf1b438d
--- /dev/null
+++ b/assets/locales/pending/geometry-kernel.json
@@ -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 上运行。"
+ }
+}
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 6d813d931a..4a84293194 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -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.
diff --git a/crates/engine/src/cmd/diagnostics.rs b/crates/engine/src/cmd/diagnostics.rs
index 5c62a1df4d..2c4d5f644a 100644
--- a/crates/engine/src/cmd/diagnostics.rs
+++ b/crates/engine/src/cmd/diagnostics.rs
@@ -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 {
+ 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 {
+ 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 {
crash::default_dir().ok_or_else(|| bad(cmd, "no crash folder on this system"))
}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 2f36c7b617..79552bf01c 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -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}");
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index e9b4a84eb9..9cff4d96de 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -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;
diff --git a/crates/engine/src/schema/diagnostics_tests.rs b/crates/engine/src/schema/diagnostics_tests.rs
index a16432ab71..e0af6b80de 100644
--- a/crates/engine/src/schema/diagnostics_tests.rs
+++ b/crates/engine/src/schema/diagnostics_tests.rs
@@ -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]
diff --git a/crates/geom/src/tess/uv.rs b/crates/geom/src/tess/uv.rs
index bd45eba6d2..fe944c43e8 100644
--- a/crates/geom/src/tess/uv.rs
+++ b/crates/geom/src/tess/uv.rs
@@ -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);
diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml
index 975d01f923..2ba7d096a0 100644
--- a/crates/gpu/Cargo.toml
+++ b/crates/gpu/Cargo.toml
@@ -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).
diff --git a/crates/io/src/design.rs b/crates/io/src/design.rs
index f097b28aef..5481075433 100644
--- a/crates/io/src/design.rs
+++ b/crates/io/src/design.rs
@@ -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::>());
+ assert_eq!(tree.as_object().unwrap().len(), 12, "{:?}", tree.as_object().unwrap().keys().collect::>());
}
/// #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]
diff --git a/crates/io/src/design/native_cache.rs b/crates/io/src/design/native_cache.rs
index d50df84d04..90cc117078 100644
--- a/crates/io/src/design/native_cache.rs
+++ b/crates/io/src/design/native_cache.rs
@@ -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 {
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>
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")))
}
diff --git a/crates/kernel/Cargo.toml b/crates/kernel/Cargo.toml
index 7d922123c6..29afa888f7 100644
--- a/crates/kernel/Cargo.toml
+++ b/crates/kernel/Cargo.toml
@@ -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]
diff --git a/crates/kernel/src/blend_smlib.rs b/crates/kernel/src/blend_smlib.rs
index 2512d5395c..46615328cc 100644
--- a/crates/kernel/src/blend_smlib.rs
+++ b/crates/kernel/src/blend_smlib.rs
@@ -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
diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs
index a08edcc2cb..993e0af631 100644
--- a/crates/kernel/src/lib.rs
+++ b/crates/kernel/src/lib.rs
@@ -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::{
diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs
index 542e601bec..6868a069c2 100644
--- a/crates/kernel/src/prism.rs
+++ b/crates/kernel/src/prism.rs
@@ -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() -> Result {
- 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 {
- refused()
- }
-
- pub(super) fn combine(a: &Sheet, _: &Sheet, _: BoolOp) -> Result> {
- match *a {}
- }
-
- pub(super) fn regions(sheet: &Sheet, _: f64) -> Result {
- match *sheet {}
- }
-}
-
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod planar_tests {
diff --git a/crates/kernel/src/prism_curve.rs b/crates/kernel/src/prism_curve.rs
index 121112ae10..4b4e47aa0a 100644
--- a/crates/kernel/src/prism_curve.rs
+++ b/crates/kernel/src/prism_curve.rs
@@ -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> {
read(run, plane, tol, work, true)
}
diff --git a/crates/kernel/src/profile.rs b/crates/kernel/src/profile.rs
index deb156c716..f0bd876c9e 100644
--- a/crates/kernel/src/profile.rs
+++ b/crates/kernel/src/profile.rs
@@ -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:?}");
}
}
}
diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs
index 043c639688..a86bac8d31 100644
--- a/crates/kernel/src/step_in/tests.rs
+++ b/crates/kernel/src/step_in/tests.rs
@@ -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}");
diff --git a/crates/kernel/src/taper_mesh_tests.rs b/crates/kernel/src/taper_mesh_tests.rs
index 14ba6fa847..12287b7c86 100644
--- a/crates/kernel/src/taper_mesh_tests.rs
+++ b/crates/kernel/src/taper_mesh_tests.rs
@@ -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
diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs
index 261cf029c1..b3fdff69a4 100644
--- a/crates/kernel/src/tests.rs
+++ b/crates/kernel/src/tests.rs
@@ -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;
diff --git a/crates/kernel/src/validity.rs b/crates/kernel/src/validity.rs
index 578331e162..73fdfe1437 100644
--- a/crates/kernel/src/validity.rs
+++ b/crates/kernel/src/validity.rs
@@ -56,20 +56,12 @@ fn smlib_validity(body: &Body) -> Option {
/// 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}")))
}
diff --git a/crates/smlib/Cargo.toml b/crates/smlib/Cargo.toml
index 2647a9945b..bc849548ae 100644
--- a/crates/smlib/Cargo.toml
+++ b/crates/smlib/Cargo.toml
@@ -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).
diff --git a/crates/smlib/src/check.rs b/crates/smlib/src/check.rs
index a818a5b62f..13f940076c 100644
--- a/crates/smlib/src/check.rs
+++ b/crates/smlib/src/check.rs
@@ -1,7 +1,7 @@
//! SMLib's own validity check: `SmBrep::ValidatePointers`, then `AssertValid` over the whole
//! topology graph. Tests run it on every SMLib result and every import.
-use crate::{Brep, Error, Result, error, ffi};
+use crate::{Brep, Error, Result, core, error, ffi, route};
/// How many of SMLib's tests to run.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -53,13 +53,16 @@ impl Brep {
/// Runs SMLib's validity check at `level`. A body that fails it is still `Ok`, with the
/// failures in the report; `Err` means the check could not run.
pub fn check(&self, level: CheckLevel) -> Result {
+ 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()));
diff --git a/crates/smlib/src/core.rs b/crates/smlib/src/core.rs
new file mode 100644
index 0000000000..5bbc56ac1f
--- /dev/null
+++ b/crates/smlib/src/core.rs
@@ -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);
+
+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> = 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(call: &'static str, f: impl FnOnce(&mut Kernel) -> std::result::Result) -> Result {
+ 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::().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(r: std::result::Result) -> std::result::Result, 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(
+ call: &'static str,
+ body: &Body,
+ f: impl FnOnce(&mut Kernel, &mut solidcore::topology::Brep) -> std::result::Result,
+) -> Result {
+ // 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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());
+ }
+}
diff --git a/crates/smlib/src/core/blend.rs b/crates/smlib/src/core/blend.rs
new file mode 100644
index 0000000000..f6a1e5035a
--- /dev/null
+++ b/crates/smlib/src/core/blend.rs
@@ -0,0 +1,1306 @@
+//! Fillets and chamfers on solidcore: the C API's composition round SMLib's fillet executive,
+//! ported from the fork's `capi/src/smlib_ops.cpp` (`blend_call`, `round_edges`,
+//! `chamfer_offsets`, `chamfer_edges`, `chamfer_radius`, `outer_faceuse`, `face_frame`,
+//! `sides_offsets`, `blend_failure`, `setbacks_fit`, `overflow_first`, `Picked`,
+//! `size_ok`, `points_ok`, `tolerance_ok`, `law_ok`, `law_pinches`, `law_along`, `finish`,
+//! `smlib_fillet`, `smlib_chamfer`, `smlib_blend`, `smlib_blend_dropping`, `smlib_blend_setback`,
+//! `smlib_blend_setback_open`, `smlib_fillet_variable`, `smlib_chamfer_sides`) and
+//! `capi/src/smlib_pick.h` (`pick`, `pick_edges`, `pick_vertices`).
+//!
+//! As there, every operation works on a copy of its body (`SmApiBrepCopy`), picks edges and
+//! corners by point, and drives the executive step by step (`SetFilletParameters`,
+//! `CreateFilletCorners`, the corners' bevels and setbacks, `DoFilleting`, `StitchAndOrient`).
+//!
+//! What the capi asks of fork-only SMLib code that solidcore does not have is
+//! [`Error::Unsupported`], refused where the capi would first reach it, so every error before
+//! it is the C++ path's:
+//! - a variable radius whose law has a corner (`SplitFilletFacesAtRadiusCorners`, fork 6eecf04; solidcore#430, PR #1403).
+//!
+//! solidcore's own refusals of code it has not ported (`SolidError::Unported`) are
+//! [`Error::Unsupported`] too.
+
+use std::collections::{HashMap, HashSet};
+use std::sync::PoisonError;
+
+use solidcore::EntityRef;
+use solidcore::SolidError;
+use solidcore::blend::fillet_executive::p1::{FilletLaw, FilletLawKind};
+use solidcore::blend::fillet_executive::p3::NoRoomReason;
+use solidcore::blend::fillet_executive::session::FilletSession;
+use solidcore::blend::fillet_geom::p1::{FilletCornerType, FilletErrorType};
+use solidcore::blend::fillet_section::section::GN_THUMBWEIGHT_EQUAL_AREA;
+use solidcore::blend::fillet_solver::p1::FilletSurfaceGeneratorType;
+use solidcore::geom::attribute::{Attribute, AttributeHandlers, Behavior, ids as attribute_ids};
+use solidcore::geom::vector3d::{Point3, Vec3};
+use solidcore::kernel::Kernel;
+use solidcore::mass::integrator::properties::edge_length;
+use solidcore::nurbs::b_spline_curve::p1::{BSplineCurve, BSplineCurveForm, CanonicalData, KnotType};
+use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType;
+use solidcore::query::api_queries::edge_closest_point;
+use solidcore::query::brep::evaluate_surface_normal;
+use solidcore::topology::brep::p1 as brep_p1;
+use solidcore::topology::brep::p2 as brep_p2;
+use solidcore::topology::brep::p2::ArenaNotifyState;
+use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face};
+use solidcore::topology::brep::p5::{StitchAndOrientOptions, stitch_and_orient};
+use solidcore::topology::edgeuse::p1 as eu_p1;
+use solidcore::topology::edgeuse::p3::{BinormalOptions, evaluate_binormal};
+use solidcore::topology::face::p2 as face_p2;
+use solidcore::topology::face::p4 as face_p4;
+use solidcore::topology::topology_traverser::Marks;
+use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId, FaceuseId, VertexId, edge, vertex};
+
+use super::{Body, answer, run};
+use crate::{Blend, ChamferSecond, Error, Hole, HoleEdge, MAX_POINTS, Result};
+use solidcore::topology::brep::p3::brep_copy;
+
+/// The capi's statuses as SolidCraft errors: an operation's own result.
+type Out = std::result::Result;
+
+/// `SM_ERR_INVALID_INPUT`.
+const INVALID_INPUT: i32 = 1007;
+
+/// An SMLib status as the capi passes it on (`status`, smlib_shim.h): solidcore's upstream
+/// status codes as they are; its refusals of code it has not ported as not supported.
+pub(super) fn status(e: SolidError) -> Error {
+ match e {
+ SolidError::Upstream { code, .. } => Error::Status(code),
+ SolidError::InvalidInput { .. } => Error::Status(INVALID_INPUT),
+ e if e.is_unported() => Error::Unsupported,
+ e => Error::Solidcore(e.to_string()),
+ }
+}
+
+/// `size_ok`.
+fn size_ok(x: f64) -> bool {
+ x.is_finite() && x > 0.0 && x <= 1e6
+}
+
+/// `tolerance_ok`.
+pub(super) fn tolerance_ok(t: f64) -> bool {
+ t.is_finite() && t > 0.0 && t < 1e6
+}
+
+/// `points_ok`.
+pub(super) fn points_ok(points: &[[f64; 3]], allow_none: bool) -> bool {
+ points.len() <= MAX_POINTS && (allow_none || !points.is_empty()) && points.iter().flatten().all(|x| x.is_finite())
+}
+
+pub(super) fn p3(p: [f64; 3]) -> Point3 {
+ Vec3::new(p[0], p[1], p[2])
+}
+
+/// Why a pick failed: `SMLIB_ERR_PICK_NONE` or `SMLIB_ERR_PICK_AMBIGUOUS`, and the point.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(super) enum Miss {
+ None(usize),
+ Ambiguous(usize),
+}
+
+/// `smlib_pick::pick`: the entity nearest each point, each once, with the index of the point that
+/// first picked it. A point picks none when the nearest is farther than `tol`, and is ambiguous
+/// when the second nearest is within `tol` too and nearer than the first by at most `model_tol`.
+/// An entity whose distance query returns a status other than success is skipped, as the capi
+/// skips it ([`answer`]); any other solidcore error fails the pick.
+pub(super) fn pick(
+ all: &[T],
+ points: &[[f64; 3]],
+ tol: f64,
+ model_tol: f64,
+ mut dist: impl FnMut(T, Point3) -> solidcore::Result>,
+) -> solidcore::Result, Vec), Miss>> {
+ let (mut picked, mut first) = (Vec::new(), Vec::new());
+ for (i, p) in points.iter().enumerate() {
+ let p = p3(*p);
+ let (mut best, mut d0, mut d1) = (None, f64::INFINITY, f64::INFINITY);
+ for &x in all {
+ let Some(d) = dist(x, p)? else { continue };
+ if !d.is_finite() {
+ continue;
+ }
+ if d < d0 {
+ d1 = d0;
+ d0 = d;
+ best = Some(x);
+ } else if d < d1 {
+ d1 = d;
+ }
+ }
+ let Some(best) = best.filter(|_| d0 <= tol) else {
+ return Ok(Err(Miss::None(i)));
+ };
+ if d1 <= tol && d1 - d0 <= model_tol {
+ return Ok(Err(Miss::Ambiguous(i)));
+ }
+ if !picked.contains(&best) {
+ picked.push(best);
+ first.push(i);
+ }
+ }
+ Ok(Ok((picked, first)))
+}
+
+/// `pick_edges` (`edge_distance`: `SmApiEdgeClosestPoint`).
+pub(super) fn pick_edges(
+ k: &Kernel,
+ b: &Brep,
+ points: &[[f64; 3]],
+ tol: f64,
+) -> solidcore::Result, Vec), Miss>> {
+ let all = brep_p1::edges(b, None)?;
+ pick(&all, points, tol, b.tolerance(), |e, p| Ok(answer(edge_closest_point(k, b, e, p))?.map(|c| c.closest.distance)))
+}
+
+/// `pick_vertices` (`vertex_distance`).
+fn pick_vertices(b: &Brep, points: &[[f64; 3]], tol: f64) -> solidcore::Result, Vec), Miss>> {
+ let all = brep_p1::vertices(b, None)?;
+ pick(&all, points, tol, b.tolerance(), |v, p| Ok(Some((b.vertex(v)?.point - p).length())))
+}
+
+/// An edge pick's failure as `pick_error` (ops.rs) reads it.
+pub(super) fn edge_miss(m: Miss) -> Error {
+ match m {
+ Miss::None(point) => Error::NoMatch { point },
+ Miss::Ambiguous(point) => Error::Ambiguous { point },
+ }
+}
+
+/// A corner pick's failure as `setback_error` (ops.rs) reads it: the capi writes the corner
+/// point's index to `out_failed_corner` only, so the status comes back bare.
+fn corner_miss(m: Miss) -> Error {
+ match m {
+ Miss::None(_) => Error::Status(crate::ffi::ops::ERR_PICK_NONE),
+ Miss::Ambiguous(_) => Error::Status(crate::ffi::ops::ERR_PICK_AMBIGUOUS),
+ }
+}
+
+/// A corner set back: its vertex and the setback along each of its edges (the capi's
+/// `SetBack`; the entry points take no ease, which is 0).
+struct SetBack {
+ vertex: VertexId,
+ distance: f64,
+}
+
+/// `setbacks_fit`: each setback shorter than every edge at its corner, and where an edge's
+/// other end is a setback corner too, the two together shorter than the edge.
+fn setbacks_fit(k: &Kernel, b: &Brep, setbacks: &[SetBack]) -> solidcore::Result {
+ for sb in setbacks {
+ for e in vertex::edges(b, sb.vertex, None)? {
+ let Some(length) = answer(edge_length(k, b, e, 1e-6))? else {
+ return Ok(false);
+ };
+ let far = edge::other_vertex(b, e, Some(sb.vertex))?;
+ let mut other = 0.0;
+ for o in setbacks {
+ if Some(o.vertex) == far && far != Some(sb.vertex) {
+ other = o.distance;
+ }
+ }
+ if sb.distance + other >= length {
+ return Ok(false);
+ }
+ }
+ }
+ Ok(true)
+}
+
+/// The midpoint parameter of `e` (`GetInterval().Evaluate(0.5)`).
+fn mid(b: &Brep, e: EdgeId) -> solidcore::Result {
+ Ok(b.edge(e)?.interval.evaluate(0.5))
+}
+
+/// `chamfer_radius`: the radius of the ball whose tangent points on the two faces of `e` lie
+/// `distance` back from the edge at its midpoint, `distance * tan(a / 2)` with `a` the angle
+/// between the directions into the faces; at a cusp (a knife edge) `distance` itself, the ball
+/// whose contacts the chamfer joins. `None` where the capi's returns false.
+fn chamfer_radius(k: &Kernel, b: &mut Brep, e: EdgeId, distance: f64) -> solidcore::Result> {
+ let Some(u0) = edge::blend_edgeuse(k, b, e)? else { return Ok(None) };
+ let Some(u1) = eu_p1::radial(b, u0)? else { return Ok(None) };
+ let t = mid(b, e)?;
+ let options = BinormalOptions::default();
+ let Some(e0) = answer(evaluate_binormal(k, b, u0, t, false, &options))? else { return Ok(None) };
+ let Some(e1) = answer(evaluate_binormal(k, b, u1, t, false, &options))? else { return Ok(None) };
+ // Unitize's failure (a zero vector) is the capi's `return false`.
+ let (Ok(n0), Ok(b0), Ok(b1)) = (e0.face_normal.unitize(), e0.binormal.unitize(), e1.binormal.unitize()) else { return Ok(None) };
+ let c = b0.dot(b1).clamp(-1.0, 1.0);
+ // At a cusp (a knife edge: the faces tangent within the half degree `GetBlendEdgeuse` calls
+ // tangent, folded back on each other, the binormals the same way) a is 0 and no ball lies
+ // `distance` back along both: the chamfer is the chord between the contacts of the ball of
+ // radius `distance` (fork e96898d).
+ let cusp = c > 0.0 && b1.dot(n0).abs() < (0.5 * std::f64::consts::PI / 180.0).sin();
+ let radius = if cusp { distance } else { distance * ((1.0 - c) / (1.0 + c)).sqrt() };
+ Ok((radius.is_finite() && radius > 0.0).then_some(radius))
+}
+
+/// What `round_edges` makes: the capi's kinds less the chamfer, which `chamfer_offsets` makes.
+#[derive(Clone, Copy, PartialEq)]
+enum Section<'a> {
+ Round,
+ /// The curvature-continuous section (`SM_FSG_BLEND_CURVE`): the Gⁿ generator's order 2 at the
+ /// equal-area thumbweight (`SMLIB_BLEND_THUMBWEIGHT`, fork 6148773; solidcore#2261 under
+ /// `fillet_gn_section_generator`), which takes off what the ball's circle does.
+ Curvature,
+ Chord,
+ /// A chamfer, with each edge's offset of its second surface (`chamfer_offsets`' `second`;
+ /// the sizes are the first's).
+ Chamfer(&'a [f64]),
+}
+
+/// The faces of `b`, for `made_faces`: a face whose id is new after the operation was made by
+/// it. (The capi marks faces with an attribute SMLib copies through every split; here a face
+/// split by the operation counts as made, which only matters to a blend that made no blend
+/// face but split one, never seen.)
+fn face_set(b: &Brep) -> solidcore::Result> {
+ Ok(brep_p1::faces(b, None)?.into_iter().collect())
+}
+
+/// `blend_failure`: a blend the executive found larger than its faces allow
+/// (`SM_FILERR_NO_ROOM`) is [`Error::NoRoom`]; a filleted vertex no corner type handles
+/// (`SM_FILERR_CORNER_UNSUPPORTED`, which solidcore notes under
+/// `fixes.fillet_vertex_error_diagnostics`) is [`Error::CornerUnsupported`]; any other failure
+/// its status. `corner` says `CreateFilletCorners` noted such a vertex: the corner it could not
+/// make is why a later step fails, so that note stands for it whatever the later step noted
+/// (as solidcore's own `FilletSession::do_fillet` keeps it).
+///
+/// Except a blend whose rail leaves its face refused as [`NoRoomReason::RailLeavesFace`]: that
+/// is solidcore without `fixes.fillet_cliff_overflow`, refusing every such blend where the fork
+/// decides between building it over the cliff (`SmFilletCliffOverflow.cpp`, fork 8ba4667) and
+/// refusing it. Its answer is the fork's only by chance, so it is [`Error::Unsupported`]. With
+/// the cliff overflow on, solidcore makes the fork's choice, and its refusals (each with its own
+/// reason) are [`Error::NoRoom`] like the fork's.
+fn blend_failure(s: &FilletSession<'_>, e: SolidError, corner: bool) -> Error {
+ let info = s.error_info();
+ if e.is_unported() {
+ status(e)
+ } else if corner || info.code == FilletErrorType::CornerUnsupported as i32 {
+ Error::CornerUnsupported
+ } else if info.code != FilletErrorType::NoRoom as i32 {
+ status(e)
+ } else if info.no_room_reason() == Some(NoRoomReason::RailLeavesFace) {
+ Error::Unsupported
+ } else {
+ Error::NoRoom
+ }
+}
+
+/// `round_edges`' `overflow_first` argument: with it the session stops, nothing built, when blends
+/// of several edges overflow their faces across cliffs (`SetOverflowFirst`) or (`classify_only`)
+/// always (`SetClassifyOnly`), and `stopped` gets the edges whose blends overflow
+/// (`GetOverflowEdges`; the capi's status `OVERFLOW_FIRST`, the call's failure then meaning only that)
+/// and `mitred` the edges of each two rounds mitred at a face's concave corner, two by two
+/// (`GetMitredEdges`), when the caller asks.
+struct OverflowFirst<'a> {
+ classify_only: bool,
+ stopped: &'a mut Option>,
+ mitred: Option<&'a mut Vec>,
+}
+
+/// `round_edges` and `chamfer_offsets` (with equal offsets): `edges` of `b` blended in place,
+/// edge i at `sizes[i]`, the executive driven as `SmApiFilletEdgesPerEdge` drives it with the
+/// corners' setbacks (`SmFilletCorner::SetSetBackDist`) set between making the corners and
+/// filleting, or (a chamfer) every corner bevelled; then `StitchAndOrient`. A blend that made
+/// no face is [`Error::NoEffect`]. A setback corner that is not SMLib's same-convexity N x N
+/// corner is refused with its index in `setbacks` (`Err(Ok(j))`).
+///
+/// With `patches`, the session marks the set-back corners' patches (`SetMarkCornerPatches`, fork
+/// 60af574), and after the stitch `patches` gets each corner's, in `setbacks`' order: the faces
+/// marked with its index, in the body's face order. A corner whose patch has no face is
+/// [`Error::Unsupported`].
+fn blend_edges(
+ k: &mut Kernel,
+ b: &mut Brep,
+ edges: &[EdgeId],
+ sizes: &[f64],
+ section: Section<'_>,
+ law: Option,
+ setbacks: &[SetBack],
+ patches: Option<&mut Vec>>,
+) -> solidcore::Result>> {
+ blend_edges_stopping(k, b, edges, sizes, section, law, setbacks, patches, None)
+}
+
+/// [`blend_edges`], with `round_edges`' `overflow_first` argument.
+#[allow(clippy::too_many_arguments)] // the capi's round_edges
+fn blend_edges_stopping(
+ k: &mut Kernel,
+ b: &mut Brep,
+ edges: &[EdgeId],
+ sizes: &[f64],
+ section: Section<'_>,
+ law: Option,
+ setbacks: &[SetBack],
+ patches: Option<&mut Vec>>,
+ overflow_first: Option>,
+) -> solidcore::Result>> {
+ let before = face_set(b)?;
+ let done = {
+ let mut s = FilletSession::new(k, b)?;
+ // `SetFilletParameters(b, es, sections, solvers, values, laws, -1, -1, -1, -1,
+ // continuity 1, thumbweight 1)`: the session sets the executive's global merge first, as
+ // `SmApiFilletEdges` does, and `SetFilletParameters` resets it, as in the capi.
+ let prepared = match (section, law) {
+ (Section::Round, None) => s.prepare_per_edge_constant_radius(k, edges, sizes, &[FilletSurfaceGeneratorType::Circular], 1, 1.0),
+ // The size is the start radius (`smlib_fillet_variable`).
+ (Section::Round, Some(law)) => match sizes.first() {
+ Some(&size) => s.prepare_variable_radius_edges(k, edges, size, law, 1, 1),
+ None => return Ok(Err(Err(Error::Argument))),
+ },
+ // The equal-area thumbweight by solidcore's name, never its value: solidcore#2274 moves
+ // it from -2 to 2, after which -2 is an ordinary weight.
+ (Section::Curvature, _) => {
+ s.prepare_per_edge_constant_radius(k, edges, sizes, &[FilletSurfaceGeneratorType::BlendCurve], 2, GN_THUMBWEIGHT_EQUAL_AREA)
+ }
+ (Section::Chord, _) => s.prepare_per_edge_constant_distance(k, edges, sizes, &[FilletSurfaceGeneratorType::Circular], 1, 1.0),
+ (Section::Chamfer(second), _) => chamfer_parameters(k, &mut s, edges, sizes, second),
+ };
+ let mut step = prepared.and_then(|()| s.create_corners(k, None)).map(|()| Ok(Vec::new()));
+ // A vertex `CreateFilletCorners` made no corner for, noted before a later step notes its
+ // own failure over it ([`blend_failure`]).
+ let corner = step.is_ok() && s.error_info().code == FilletErrorType::CornerUnsupported as i32;
+ s.set_overflow_first(overflow_first.is_some());
+ s.set_classify_only(overflow_first.as_ref().is_some_and(|o| o.classify_only));
+ if let Ok(Ok(_)) = step {
+ step = corners(&mut s, section, setbacks);
+ }
+ s.set_mark_corner_patches(patches.is_some());
+ match step {
+ // The marks go with the session: read them before it drops.
+ Ok(Ok(set_back)) => match s.do_filleting(k, None) {
+ Ok(()) => s.corner_patch_faces().map(|marked| Ok((set_back, marked))),
+ Err(e) => {
+ if let Some(o) = overflow_first
+ && s.error_info().code == FilletErrorType::OverflowFirst as i32
+ {
+ *o.stopped = Some(s.overflow_edges());
+ if let Some(m) = o.mitred {
+ *m = s.mitred_edges();
+ }
+ }
+ Ok(Err(Err(blend_failure(&s, e, corner))))
+ }
+ },
+ Ok(Err(refusal)) => Ok(Err(refusal)),
+ Err(e) => Ok(Err(Err(blend_failure(&s, e, corner)))),
+ }
+ };
+ let done: solidcore::Result, Vec<(usize, FaceId)>), std::result::Result>> = done;
+ let (set_back, marked) = match done? {
+ Ok(d) => d,
+ Err(refusal) => {
+ b.refresh_cached_topology();
+ return Ok(Err(refusal));
+ }
+ };
+ let stitched = stitch_and_orient(k, b, &StitchAndOrientOptions::default()).map_err(status);
+ // The port's cached topology, true again for what reads the body next (as solidcore's own
+ // fillet entry points do).
+ b.refresh_cached_topology();
+ if let Err(e) = stitched {
+ return Ok(Err(Err(e)));
+ }
+ let made = face_set(b)?.difference(&before).count();
+ if made == 0 {
+ return Ok(Err(Err(Error::NoEffect)));
+ }
+ if let Some(patches) = patches {
+ // The stitch keeps the faces filleting made: a corner's patch is the faces marked with
+ // its index.
+ let live = brep_p1::faces(b, None)?;
+ let marked: HashMap = marked.into_iter().map(|(i, f)| (f, i)).collect();
+ for &index in &set_back {
+ let patch: Vec = live.iter().copied().filter(|f| marked.get(f) == Some(&index)).collect();
+ if patch.is_empty() {
+ return Ok(Err(Err(Error::Unsupported)));
+ }
+ patches.push(patch);
+ }
+ // `RemoveAttributeFromTopology(SM_AI_FILLET_CORNER_FACE)`: the marks are the session's,
+ // and none is left on the body's own faces.
+ for f in live {
+ let at = EntityRef::Face(f);
+ let marks: Vec<_> =
+ b.entity_attributes(at).into_iter().filter(|(_, a)| a.attribute_id() == attribute_ids::FILLET_CORNER_FACE).map(|(h, _)| h).collect();
+ for h in marks {
+ b.remove_attribute(at, h)?;
+ }
+ }
+ }
+ Ok(Ok(()))
+}
+
+/// `chamfer_offsets`' `SetFilletParameters(b, es, kinds, solvers, first, NULL, -1, -1, -1, -1,
+/// 1, 1.0, &second)` with every kind `SM_FSG_LINEAR` and every solver `SM_FS_CONST_RADIUS`, as the
+/// fork's `SetFilletParameters` runs it (its `pOptSecondRadii`, fork f653ac9).
+fn chamfer_parameters(k: &mut Kernel, s: &mut FilletSession<'_>, edges: &[EdgeId], first: &[f64], second: &[f64]) -> solidcore::Result<()> {
+ if edges.is_empty() {
+ return Ok(());
+ }
+ s.prepare_per_edge_constant_radius_with_second(k, edges, first, second, &[FilletSurfaceGeneratorType::Linear], 1, 1.0)
+}
+
+/// The corners' part of `round_edges` / `chamfer_offsets`: a chamfer's corners bevelled; each
+/// setback set on its vertex's corner (`GetFilletCornerOfVertex`), which must be an
+/// `SM_FCR_N_x_N_CONVEX` corner. Each set-back corner's index in the session's corners, in
+/// `setbacks`' order.
+fn corners(
+ s: &mut FilletSession<'_>,
+ section: Section<'_>,
+ setbacks: &[SetBack],
+) -> solidcore::Result, std::result::Result>> {
+ let ids = s.fillet_corners()?;
+ if matches!(section, Section::Chamfer(_)) {
+ for id in ids {
+ s.set_corner_bevel(id, true)?;
+ }
+ return Ok(Ok(Vec::new()));
+ }
+ let mut set_back = Vec::with_capacity(setbacks.len());
+ for (j, sb) in setbacks.iter().enumerate() {
+ let mut found = None;
+ for (i, &id) in ids.iter().enumerate() {
+ if s.corner(id)?.vertex == Some(sb.vertex) {
+ found = Some((i, id));
+ break;
+ }
+ }
+ let Some((i, id)) = found else {
+ return Ok(Err(Err(Error::Argument)));
+ };
+ if s.corner(id)?.corner_type() != FilletCornerType::NByNConvex {
+ return Ok(Err(Ok(j)));
+ }
+ s.set_corner_setback(id, sb.distance)?;
+ set_back.push(i);
+ }
+ Ok(Ok(set_back))
+}
+
+/// `finish`: the result must have faces, and a solid input must give a closed manifold solid.
+pub(super) fn finish(b: Brep, solid_in: bool) -> solidcore::Result> {
+ 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))
+}
+
+/// Runs `f` on a copy of `body` (`brep_copy`) under the kernel's lock.
+pub(super) fn on_copy(call: &'static str, body: &Body, f: impl FnOnce(&mut Kernel, Brep) -> solidcore::Result>) -> Result {
+ if body.0.is_poisoned() {
+ return Err(Error::Internal(format!("{call}: the body was left unfinished by an earlier panic")));
+ }
+ run(call, |k| {
+ let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
+ super::dump::input(k, "brep", &g);
+ let c = brep_copy(k, &g)?;
+ drop(g);
+ f(k, c)
+ })?
+ .map(Body::new)
+}
+
+/// What [`blend`] does with the picked corners' patches.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Corners {
+ /// Filled, as SMLib builds them (`smlib_blend`, `smlib_blend_setback`).
+ Filled,
+ /// Removed, the holes listed (`smlib_blend_setback_open`).
+ Open,
+}
+
+/// `Picked::ID`: above SMLib's own ids (`SM_AI_USER_1 + 9`).
+const PICKED_ID: u64 = attribute_ids::USER_1 + 9;
+
+/// The capi's `Picked`: which picked edge an edge stands for, put on the picked edges before an
+/// overflowing blend goes first, handed on through every split and copy (`SM_AB_COPY`), so
+/// afterwards the edges carrying it are what the overflow left of that picked edge (none: it
+/// removed the edge whole).
+#[derive(Debug, Clone, PartialEq)]
+struct Picked(usize);
+
+impl AttributeHandlers for Picked {}
+
+/// The attribute `Picked` on `e`, with its handle.
+fn picked_on(b: &Brep, e: EdgeId) -> Option<(solidcore::geom::attribute::AttributeHandle, usize)> {
+ let (h, a) = b.entity_attributes(EntityRef::Edge(e)).into_iter().find(|(_, a)| a.attribute_id() == PICKED_ID)?;
+ a.application_value::().map(|p| (h, p.0))
+}
+
+/// Whether the rounds that overflow go first ([`overflow_first`]): on since solidcore#1980, which
+/// keeps the attributes of the target edges the subtraction replaces, so a picked edge the overflow
+/// only shortened still reads as picked.
+const OVERFLOW_GOES_FIRST: bool = true;
+
+/// `overflow_first`: the rounds of [`blend_call`] when blends of several edges overflow their
+/// faces (`round_edges` stopped in the copy `w` of `body`, `picked` its picked edges, `each` their
+/// sizes). The blends that overflow on their own (each edge's alone, classified in `w`, which
+/// nothing was built in) go first: on a fresh copy of `body`, the picked edges tagged with their
+/// places ([`Picked`]), those are rounded, and each other picked edge is then what is left of it,
+/// the edges carrying its tag, rounded at its size. A picked edge with nothing left is gone: its
+/// points are flagged in `dropped`. Two rounds mitred at a face's concave corner (`mitred`, the edges
+/// of each such pair two by two, as the stopped call found them in `w`) meet in their mitre, each run
+/// on past the corner: one goes first with the other, so a round mitred to one that overflows goes
+/// first too (fork 9ba2b12). Where none overflows on its own, or all go first, the edges are rounded
+/// together as before. The result is the new body. Unlike the capi, the tags come off it
+/// (nothing reads them after the call, and a later call's copy would carry them on).
+#[allow(clippy::too_many_arguments)] // the capi's overflow_first
+fn overflow_first(
+ k: &mut Kernel,
+ body: &Body,
+ points: &[[f64; 3]],
+ tolerance: f64,
+ section: Section<'_>,
+ picked: &[EdgeId],
+ mitred: &[EdgeId],
+ each: &[f64],
+ w: &mut Brep,
+ dropped: &mut [bool],
+) -> solidcore::Result> {
+ /// A failure of `round_edges` on the fresh copy, which has no setback corners.
+ fn failed(e: std::result::Result) -> Error {
+ e.err().unwrap_or(Error::Unsupported)
+ }
+ let mut fresh = {
+ let g = body.0.lock().unwrap_or_else(PoisonError::into_inner);
+ brep_copy(k, &g)?
+ };
+ let again = match pick_edges(k, &fresh, points, tolerance)? {
+ Ok((again, _)) if again.len() == picked.len() => again,
+ Ok(_) => return Ok(Err(Error::NoMatch { point: 0 })),
+ Err(m) => return Ok(Err(edge_miss(m))),
+ };
+ let mut goes_first = vec![false; picked.len()];
+ for (i, ((&e, &to), &size)) in picked.iter().zip(&again).zip(each).enumerate() {
+ let mut stopped = None;
+ let alone = OverflowFirst { classify_only: true, stopped: &mut stopped, mitred: None };
+ match blend_edges_stopping(k, w, &[e], &[size], section, None, &[], None, Some(alone))? {
+ Err(Err(why)) if stopped.is_none() => return Ok(Err(why)),
+ Err(Ok(_)) => return Ok(Err(Error::Unsupported)),
+ Ok(()) => return Ok(Err(Error::Status(1001))),
+ Err(Err(_)) => {}
+ }
+ fresh.add_attribute(EntityRef::Edge(to), Attribute::application(PICKED_ID, Behavior::Copy, Picked(i)))?;
+ if let Some(flag) = goes_first.get_mut(i) {
+ *flag = stopped.is_some_and(|over| !over.is_empty());
+ }
+ }
+ // A round mitred to one that goes first goes with it, through every mitre.
+ let place = |e: &EdgeId| picked.iter().position(|p| p == e);
+ let pairs: Vec<(usize, usize)> = mitred.as_chunks::<2>().0.iter().filter_map(|[a, b]| Some((place(a)?, place(b)?))).collect();
+ let mut grew = true;
+ while grew {
+ grew = false;
+ for &(a, b) in &pairs {
+ if goes_first.get(a) != goes_first.get(b) {
+ for i in [a, b] {
+ if let Some(flag) = goes_first.get_mut(i) {
+ *flag = true;
+ }
+ }
+ grew = true;
+ }
+ }
+ }
+ let (mut lead, mut lead_sizes, mut rest) = (Vec::new(), Vec::new(), Vec::new());
+ for (i, ((&to, &size), &first)) in again.iter().zip(each).zip(&goes_first).enumerate() {
+ if first {
+ lead.push(to);
+ lead_sizes.push(size);
+ } else {
+ rest.push(i);
+ }
+ }
+ if lead.is_empty() || rest.is_empty() {
+ return Ok(match blend_edges(k, &mut fresh, &again, each, section, None, &[], None)? {
+ Ok(()) => untagged(fresh),
+ Err(e) => Err(failed(e)),
+ });
+ }
+ if let Err(e) = blend_edges(k, &mut fresh, &lead, &lead_sizes, section, None, &[], None)? {
+ return Ok(Err(failed(e)));
+ }
+ let edges = brep_p1::edges(&fresh, None)?;
+ let (mut rest_edges, mut rest_sizes) = (Vec::new(), Vec::new());
+ for i in rest {
+ let mut left = false;
+ for &e in &edges {
+ if picked_on(&fresh, e).is_some_and(|(_, p)| p == i) {
+ rest_edges.push(e);
+ rest_sizes.push(each.get(i).copied().unwrap_or(0.0));
+ left = true;
+ }
+ }
+ if !left {
+ // Every point that picked edge i (in `w`, which nothing was built in).
+ for (j, p) in points.iter().enumerate() {
+ if let (Ok((one, _)), Some(flag)) = (pick_edges(k, w, std::slice::from_ref(p), tolerance)?, dropped.get_mut(j))
+ && one.as_slice() == picked.get(i..=i).unwrap_or_default()
+ {
+ *flag = true;
+ }
+ }
+ }
+ }
+ if !rest_edges.is_empty()
+ && let Err(e) = blend_edges(k, &mut fresh, &rest_edges, &rest_sizes, section, None, &[], None)?
+ {
+ return Ok(Err(failed(e)));
+ }
+ Ok(untagged(fresh))
+}
+
+/// `b` with [`Picked`] off its edges.
+fn untagged(mut b: Brep) -> Out {
+ let edges = brep_p1::edges(&b, None).map_err(status)?;
+ for e in edges {
+ if let Some((h, _)) = picked_on(&b, e) {
+ b.remove_attribute(EntityRef::Edge(e), h).map_err(status)?;
+ }
+ }
+ Ok(b)
+}
+
+/// Why a curvature fillet over edges of different wedge angles is refused. The equal-area
+/// thumbweight is resolved once per call, from the first blend's wedge, and every edge takes it
+/// (in the fork as in solidcore#2261): an edge of another wedge takes off another area than its
+/// own, and which one depends on the edges' order.
+pub(crate) const CURVATURE_MIXED_WEDGES: &str = "a curvature-continuous fillet on solidcore over edges whose faces meet at different \
+angles: every edge takes the equal-area thumbweight of the first one's angle, so the others take off the wrong area \
+(https://github.com/tangency-labs/solidcore/issues/2250#issuecomment-6563419897)";
+
+/// How far apart, in radians, two edges' wedge angles may be and still share one equal-area
+/// thumbweight.
+const WEDGE_TOLERANCE: f64 = 1e-6;
+
+/// Whether the faces of every edge in `edges` meet at the same angle at its midpoint (the wedge the
+/// equal-area thumbweight is resolved from), within [`WEDGE_TOLERANCE`]. An edge whose angle can't
+/// be read counts as another wedge.
+fn one_wedge(k: &Kernel, b: &mut Brep, edges: &[EdgeId]) -> solidcore::Result {
+ let mut first: Option = None;
+ for &e in edges {
+ let Some(u0) = edge::blend_edgeuse(k, b, e)? else { return Ok(false) };
+ let Some(u1) = eu_p1::radial(b, u0)? else { return Ok(false) };
+ let (Some((n0, _)), Some((n1, _))) = (face_frame(k, b, u0)?, face_frame(k, b, u1)?) else { return Ok(false) };
+ let angle = n0.dot(n1).clamp(-1.0, 1.0).acos();
+ match first {
+ None => first = Some(angle),
+ Some(a) if (a - angle).abs() > WEDGE_TOLERANCE => return Ok(false),
+ Some(_) => {}
+ }
+ }
+ Ok(true)
+}
+
+/// [`blend_holes`] without its holes.
+#[allow(clippy::too_many_arguments)] // the capi's blend_call
+pub(crate) fn blend(
+ body: &Body,
+ kind: Blend,
+ points: &[[f64; 3]],
+ sizes: &[f64],
+ tolerance: f64,
+ corners: &[[f64; 3]],
+ setbacks: &[f64],
+ open: Corners,
+) -> Result {
+ blend_holes(body, kind, points, sizes, tolerance, corners, setbacks, open).map(|(b, _)| b)
+}
+
+/// `smlib_blend_dropping`: [`blend`] with one size per point, no corners, and per point whether it
+/// picked an edge that a larger round, overflowing its faces, removed whole.
+pub(crate) fn blend_dropping(body: &Body, kind: Blend, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Body, Vec)> {
+ let mut dropped = vec![false; points.len()];
+ let (b, _) = blend_call(body, kind, points, sizes, tolerance, &[], &[], Corners::Filled, &mut dropped)?;
+ Ok((b, dropped))
+}
+
+/// `blend_call` (as `smlib_fillet`, `smlib_chamfer`, `smlib_blend`, `smlib_blend_setback` and
+/// `smlib_blend_setback_open` call it): `sizes` one per point, or one for all; `corners` and
+/// `setbacks` the corner setbacks. With [`Corners::Open`], the holes, one per point of
+/// `corners` (none otherwise).
+#[allow(clippy::too_many_arguments)] // the capi's blend_call
+pub(crate) fn blend_holes(
+ body: &Body,
+ kind: Blend,
+ points: &[[f64; 3]],
+ sizes: &[f64],
+ tolerance: f64,
+ corners: &[[f64; 3]],
+ setbacks: &[f64],
+ open: Corners,
+) -> Result<(Body, Vec)> {
+ blend_call(body, kind, points, sizes, tolerance, corners, setbacks, open, &mut vec![false; points.len()])
+}
+
+/// `blend_call`: [`blend_holes`], with per point in `dropped` whether it picked an edge an
+/// overflowing round removed whole ([`overflow_first`]).
+#[allow(clippy::too_many_arguments)] // the capi's blend_call
+fn blend_call(
+ body: &Body,
+ kind: Blend,
+ points: &[[f64; 3]],
+ sizes: &[f64],
+ tolerance: f64,
+ corners: &[[f64; 3]],
+ setbacks: &[f64],
+ open: Corners,
+ dropped: &mut [bool],
+) -> Result<(Body, Vec)> {
+ let one_size = sizes.len() == 1;
+ if !points_ok(points, false)
+ || !tolerance_ok(tolerance)
+ || !(one_size || sizes.len() == points.len())
+ || !points_ok(corners, true)
+ || setbacks.len() != corners.len()
+ {
+ return Err(Error::Argument);
+ }
+ if !sizes.iter().all(|&s| size_ok(s)) || !setbacks.iter().all(|&s| size_ok(s)) {
+ return Err(Error::Argument);
+ }
+ // A chamfer's corners are bevelled, which builds no setback.
+ if !corners.is_empty() && kind == Blend::Chamfer {
+ return Err(Error::Unsupported);
+ }
+ let mut holes = Vec::new();
+ let made = on_copy("blend_call", body, |k, mut w| {
+ let (picked, first) = match pick_edges(k, &w, points, tolerance)? {
+ Ok(p) => p,
+ Err(m) => return Ok(Err(edge_miss(m))),
+ };
+ let each: Vec = first.iter().map(|&i| sizes.get(if one_size { 0 } else { i }).copied().unwrap_or(0.0)).collect();
+ // Each corner point picks a vertex; a vertex picked twice takes the first point's setback.
+ let (at, first_corner) = match pick_vertices(&w, corners, tolerance)? {
+ Ok(p) => p,
+ Err(m) => return Ok(Err(corner_miss(m))),
+ };
+ let back: Vec =
+ at.iter().zip(&first_corner).map(|(&vertex, &j)| SetBack { vertex, distance: setbacks.get(j).copied().unwrap_or(0.0) }).collect();
+ if !setbacks_fit(k, &w, &back)? {
+ return Ok(Err(Error::NoRoom));
+ }
+ let solid_in = brep_p2::is_manifold_solid(&w)?;
+ let section = match kind {
+ Blend::Round => Section::Round,
+ // Every edge of one call takes the equal-area thumbweight of the first's wedge
+ // (solidcore#2250's comment 6563419897): edges of other wedges would take off another
+ // area than their own, so they are not built together.
+ Blend::Curvature if !one_wedge(k, &mut w, &picked)? => return Ok(Err(Error::Unported(CURVATURE_MIXED_WEDGES))),
+ Blend::Curvature => Section::Curvature,
+ Blend::Chord => Section::Chord,
+ // `chamfer_edges`: each edge's offsets from its setback at its midpoint.
+ Blend::Chamfer => {
+ let mut radii = Vec::with_capacity(picked.len());
+ for (&e, &d) in picked.iter().zip(&each) {
+ match chamfer_radius(k, &mut w, e, d)? {
+ Some(r) => radii.push(r),
+ None => return Ok(Err(Error::Status(INVALID_INPUT))),
+ }
+ }
+ return Ok(match blend_edges(k, &mut w, &picked, &radii, Section::Chamfer(&radii), None, &[], None)? {
+ Ok(()) => finish(w, solid_in)?,
+ Err(Ok(_)) => Err(Error::Unsupported),
+ Err(Err(e)) => Err(e),
+ });
+ }
+ };
+ // The corners' places, read now: filleting deletes the vertices.
+ let at_points = at.iter().map(|&v| vertex::point(&w, v)).collect::>>()?;
+ let mut patches = Vec::new();
+ // An overflow goes first (rounds without corner setbacks): when some of the rounds
+ // overflow their faces across cliffs and others fit, the overflowing ones are built
+ // first, on a fresh copy, and the others then round what is left of their edges. An edge
+ // the overflow removes whole is gone (`dropped`); one it cuts keeps the rest.
+ let may_go_first = OVERFLOW_GOES_FIRST && back.is_empty() && open == Corners::Filled && picked.len() > 1;
+ let (mut stopped, mut mitred) = (None, Vec::new());
+ let first = may_go_first.then_some(OverflowFirst { classify_only: false, stopped: &mut stopped, mitred: Some(&mut mitred) });
+ let done = blend_edges_stopping(k, &mut w, &picked, &each, section, None, &back, (open == Corners::Open).then_some(&mut patches), first)?;
+ if stopped.is_some() {
+ return Ok(match overflow_first(k, body, points, tolerance, section, &picked, &mitred, &each, &mut w, dropped)? {
+ Ok(fresh) => finish(fresh, solid_in)?,
+ Err(e) => Err(e),
+ });
+ }
+ match done {
+ Ok(()) => {}
+ // A corner SMLib does not set back: the first of the caller's points that picked it.
+ Err(Ok(j)) => return Ok(Err(first_corner.get(j).map_or(Error::Unsupported, |&corner| Error::CornerRefused { corner }))),
+ Err(Err(e)) => return Ok(Err(e)),
+ }
+ if open == Corners::Filled {
+ return finish(w, solid_in);
+ }
+ // Each corner opened: its patch removed, the edges round its hole in the result's
+ // orders, once per corner point (a vertex picked twice gives its hole twice).
+ if !brep_p2::is_manifold_solid(&w)? {
+ return Ok(Err(Error::NotSolid));
+ }
+ let rims = match open_corners(k, &mut w, &patches)? {
+ Ok(r) => r,
+ Err(e) => return Ok(Err(e)),
+ };
+ let edge_index: HashMap = brep_p1::edges(&w, None)?.into_iter().enumerate().map(|(i, e)| (e, i)).collect();
+ let face_index: HashMap = brep_p1::faces(&w, None)?.into_iter().enumerate().map(|(i, f)| (f, i)).collect();
+ for c in corners {
+ let p = Point3::new(c[0], c[1], c[2]);
+ let mut which = None;
+ let mut best = f64::INFINITY;
+ for (j, q) in at_points.iter().enumerate() {
+ let d = (*q - p).length();
+ if d < best {
+ best = d;
+ which = Some(j);
+ }
+ }
+ let Some(rim) = which.and_then(|j| rims.get(j)) else { return Ok(Err(Error::Argument)) };
+ let mut edges = Vec::with_capacity(rim.len());
+ for h in rim {
+ let (Some(&edge), Some(&face)) = (edge_index.get(&h.edge), face_index.get(&h.face)) else { return Ok(Err(Error::Unsupported)) };
+ edges.push(HoleEdge { edge, face, forward: h.forward });
+ }
+ holes.push(Hole { edges });
+ }
+ finish(w, false)
+ })?;
+ Ok((made, holes))
+}
+
+/// One edge round an opened corner's hole (`HoleEdge`): the face beside it, and whether that
+/// face's boundary, run counter-clockwise about its outward normal (the solid's), runs the edge
+/// from its start vertex to its end.
+#[derive(Clone, Copy)]
+struct RimEdge {
+ edge: EdgeId,
+ face: FaceId,
+ forward: bool,
+}
+
+/// `open_corners`: the faces of each patch in `patches` removed from the solid `b`, with the edges
+/// and vertices only they used, and the edges round each patch's hole, as the faces beside it run
+/// them, each edge's start the previous one's end. Read on the solid before anything is removed,
+/// where each face's outward side is known. Each edge round a hole must lie between a face of its
+/// patch and one other face, outside every patch, and the edges must close one loop (a patch that
+/// is a disc), else [`Error::Unsupported`].
+fn open_corners(k: &mut Kernel, b: &mut Brep, patches: &[Vec]) -> solidcore::Result>>> {
+ let any: HashSet = patches.iter().flatten().copied().collect();
+ let mut holes = Vec::with_capacity(patches.len());
+ for patch in patches {
+ let mine: HashSet = patch.iter().copied().collect();
+ let mut seen = HashSet::new();
+ let mut rim: Vec = Vec::new();
+ for &f in patch {
+ for e in face_p4::edges(b, f, None, false, None)? {
+ if !seen.insert(e) {
+ continue;
+ }
+ let beside = edge::faces(b, e, None)?;
+ let mut outside = beside.iter().copied().filter(|g| !mine.contains(g));
+ let (Some(other), more) = (outside.next(), outside.next()) else { continue };
+ if more.is_some() || beside.len() != 2 || any.contains(&other) {
+ return Ok(Err(Error::Unsupported));
+ }
+ let Some(fu) = outer_faceuse(b, other)? else { return Ok(Err(Error::Unsupported)) };
+ let mut used = None;
+ for u in edge::edgeuses(b, e, None, None)? {
+ if eu_p1::faceuse(b, u)? == Some(fu) {
+ used = Some(u);
+ }
+ }
+ let Some(u) = used else { return Ok(Err(Error::Unsupported)) };
+ rim.push(RimEdge { edge: e, face: other, forward: b.edgeuse(u)?.orientation == OrientType::Same });
+ }
+ }
+ let ends = |h: &RimEdge| -> solidcore::Result<(Option, Option)> {
+ let (s, t) = edge::start_end_vertices(b, h.edge)?;
+ Ok(if h.forward { (s, t) } else { (t, s) })
+ };
+ let Some(&front) = rim.first() else { return Ok(Err(Error::Unsupported)) };
+ let start = ends(&front)?.0;
+ let mut used = vec![false; rim.len()];
+ if let Some(u) = used.first_mut() {
+ *u = true;
+ }
+ let mut hole = vec![front];
+ let mut end = ends(&front)?.1;
+ // Each turn takes a rim edge not yet in the hole, so it ends within the rim's length.
+ while end != start {
+ let mut next = None;
+ for (i, h) in rim.iter().enumerate() {
+ if !used.get(i).copied().unwrap_or(true) && ends(h)?.0 == end {
+ if next.is_some() {
+ return Ok(Err(Error::Unsupported));
+ }
+ next = Some((i, *h));
+ }
+ }
+ let Some((i, h)) = next else { return Ok(Err(Error::Unsupported)) };
+ if let Some(u) = used.get_mut(i) {
+ *u = true;
+ }
+ hole.push(h);
+ end = ends(&h)?.1;
+ }
+ if hole.len() != rim.len() {
+ return Ok(Err(Error::Unsupported));
+ }
+ holes.push(hole);
+ }
+ // `DeleteFace(f, TRUE)` with the B-rep open for editing, each face once, in the patches'
+ // order.
+ let mut order = Vec::with_capacity(any.len());
+ let mut taken = HashSet::new();
+ for &f in patches.iter().flatten() {
+ if taken.insert(f) {
+ order.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 order {
+ let options = DeleteFaceOptions { delete_edges_vertices: 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(match deleted {
+ Ok(()) => Ok(holes),
+ Err(e) => Err(status(e)),
+ })
+}
+
+/// The faceuse of `f` on the outside of its body (`outer_faceuse`): the one bounding the
+/// infinite region (for a sheet, the upward one).
+fn outer_faceuse(b: &Brep, f: FaceId) -> solidcore::Result> {
+ let Some(fu) = face_p2::upward_faceuse(b, f)? else { return Ok(None) };
+ let shell = b.faceuse(fu)?.shell;
+ let region = match shell {
+ Some(sh) => b.shell(sh)?.region,
+ None => None,
+ };
+ if shell.is_none() || region != b.infinite_region() { Ok(b.faceuse(fu)?.mate) } else { Ok(Some(fu)) }
+}
+
+/// `face_frame`: the outward unit normal, at the edge's midpoint, of the face the edge use `u`
+/// bounds, and the unit direction into that face square to the edge.
+fn face_frame(k: &Kernel, b: &mut Brep, u: EdgeuseId) -> solidcore::Result > {
+ let Some(e) = b.edgeuse(u)?.edge else { return Ok(None) };
+ let fu_of_lu = match eu_p1::loopuse(b, u)? {
+ Some(lu) => b.loopuse(lu)?.faceuse,
+ None => None,
+ };
+ let f = match fu_of_lu {
+ Some(fu) => b.faceuse(fu)?.face,
+ None => None,
+ };
+ let Some(f) = f else { return Ok(None) };
+ let Some(fu) = outer_faceuse(b, f)? else { return Ok(None) };
+ if b.face(f)?.surface.is_none() {
+ return Ok(None);
+ }
+ let t = mid(b, e)?;
+ let Some(at) = answer(evaluate_binormal(k, b, u, t, false, &BinormalOptions::default()))? else { return Ok(None) };
+ let Some(normal) = answer(evaluate_surface_normal(k, b.face(f)?.surface.as_ref(), at.uv))? else { return Ok(None) };
+ let len = normal.length();
+ if !(len > 0.0 && len.is_finite()) {
+ return Ok(None);
+ }
+ let sign = if b.faceuse(fu)?.orientation == OrientType::Same { 1.0 } else { -1.0 };
+ Ok(Some((normal * (sign / len), at.binormal)))
+}
+
+/// `sides_offsets`: the surface offsets of `e` (its blend edge use's surface first) at which
+/// `chamfer_offsets` cuts a chamfer `d1` back along the face whose outward normal at the edge's
+/// midpoint leans most along `up`, and `second` back along the other (or at the angle `second`
+/// to the first face). With b0, b1 the directions into the faces and θ the angle between them,
+/// setbacks s0, s1 lie at offsets r0 = (s1 - s0 cos θ) / sin θ and r1 = (s0 - s1 cos θ) / sin θ;
+/// an angle φ to the first face gives s1 = s0 sin φ / sin(θ + φ).
+fn sides_offsets(k: &Kernel, b: &mut Brep, e: EdgeId, d1: f64, second: ChamferSecond, up: Vec3) -> solidcore::Result > {
+ let Some(u0) = edge::blend_edgeuse(k, b, e)? else { return Ok(None) };
+ let Some(u1) = eu_p1::radial(b, u0)? else { return Ok(None) };
+ let (Some((n0, b0)), Some((n1, b1))) = (face_frame(k, b, u0)?, face_frame(k, b, u1)?) else { return Ok(None) };
+ let c = b0.dot(b1).clamp(-1.0, 1.0);
+ let sn = (1.0 - c * c).max(0.0).sqrt();
+ if !(sn > 1e-6) {
+ return Ok(None);
+ }
+ let mut s0 = d1;
+ let mut s1 = match second {
+ ChamferSecond::Distance(d) => d,
+ ChamferSecond::Angle(phi) => {
+ let d = (c.acos() + phi).sin();
+ if !(d > 1e-9) {
+ return Ok(None);
+ }
+ d1 * phi.sin() / d
+ }
+ };
+ if n1.dot(up) > n0.dot(up) {
+ std::mem::swap(&mut s0, &mut s1);
+ }
+ let (r0, r1) = ((s1 - s0 * c) / sn, (s0 - s1 * c) / sn);
+ Ok((r0.is_finite() && r1.is_finite() && r0 > 0.0 && r1 > 0.0).then_some((r0, r1)))
+}
+
+/// `smlib_chamfer_sides`: the edges nearest `points` chamfered `distance` back along the face
+/// whose outward normal leans most along `first`, and by `second` on the other, with bevelled
+/// corners.
+pub(crate) fn chamfer_sides(body: &Body, points: &[[f64; 3]], tolerance: f64, distance: f64, second: ChamferSecond, first: [f64; 3]) -> Result {
+ let second_ok = match second {
+ ChamferSecond::Angle(a) => a.is_finite() && a > 1e-6 && a < std::f64::consts::PI - 1e-6,
+ ChamferSecond::Distance(d) => size_ok(d),
+ };
+ if !points_ok(points, false) || !tolerance_ok(tolerance) || !size_ok(distance) || !second_ok || !first.iter().all(|x| x.is_finite()) {
+ return Err(Error::Argument);
+ }
+ let up = p3(first);
+ if !(up.length() > 0.0) {
+ return Err(Error::Argument);
+ }
+ on_copy("smlib_chamfer_sides", body, |k, mut w| {
+ let picked = match pick_edges(k, &w, points, tolerance)? {
+ Ok((p, _)) => p,
+ Err(m) => return Ok(Err(edge_miss(m))),
+ };
+ let solid_in = brep_p2::is_manifold_solid(&w)?;
+ let (mut r0, mut r1) = (Vec::with_capacity(picked.len()), Vec::with_capacity(picked.len()));
+ for &e in &picked {
+ match sides_offsets(k, &mut w, e, distance, second, up)? {
+ Some((a, b)) => {
+ r0.push(a);
+ r1.push(b);
+ }
+ None => return Ok(Err(Error::Status(INVALID_INPUT))),
+ }
+ }
+ Ok(match blend_edges(k, &mut w, &picked, &r0, Section::Chamfer(&r1), None, &[], None)? {
+ Ok(()) => finish(w, solid_in)?,
+ Err(Ok(_)) => Err(Error::Unsupported),
+ Err(Err(e)) => Err(e),
+ })
+ })
+}
+
+/// `law_ok`: a clamped B-spline of `degree` 1 to 3 from 0 to 1, 2 to 100 control radii, each
+/// finite, at least 0 and at most 1e6.
+fn law_ok(degree: usize, knots: &[f64], radii: &[f64]) -> bool {
+ let n = radii.len();
+ if !(1..=3).contains(°ree) || !(2..=100).contains(&n) || n <= degree || knots.len() != n + degree + 1 {
+ return false;
+ }
+ if !knots.iter().chain(radii).all(|x| x.is_finite()) {
+ return false;
+ }
+ for (i, &k) in knots.iter().enumerate() {
+ let (low_end, high_end) = (i <= degree, i >= n);
+ if (low_end && k != 0.0) || (high_end && k != 1.0) || (i > 0 && knots.get(i - 1).is_some_and(|&p| k < p)) {
+ return false;
+ }
+ }
+ radii.iter().all(|&r| (0.0..=1e6).contains(&r))
+}
+
+/// `law_pinches`: whether the law's radius is zero somewhere inside (0, 1).
+fn law_pinches(degree: usize, knots: &[f64], radii: &[f64]) -> bool {
+ let n = radii.len() as i64;
+ let p = degree as i64;
+ let all_zero = |from: i64, to: i64| -> bool {
+ let lo = from.max(0);
+ let mut j = lo;
+ while j <= to && j < n {
+ if radii.get(j as usize).is_some_and(|&r| r != 0.0) {
+ return false;
+ }
+ j += 1;
+ }
+ from <= to
+ };
+ let kn = |i: i64| knots.get(i as usize).copied().unwrap_or(f64::NAN);
+ let n_knots = n + p + 1;
+ let mut i = p;
+ while i + 1 < n_knots && i < n {
+ if kn(i) < kn(i + 1) && all_zero(i - p, i) {
+ return true;
+ }
+ i += 1;
+ }
+ let mut s = p + 1;
+ while s < n {
+ let mut m = 1;
+ while s + m < n && kn(s + m) == kn(s) {
+ m += 1;
+ }
+ if all_zero(s + m - 1 - p, s - 1) {
+ return true;
+ }
+ s += m;
+ }
+ false
+}
+
+/// Whether the law has a corner inside (0, 1) (`SmBSplineFilletLaw::GetCornerParameters`, fork
+/// 6eecf04): an interior knot where the radius' slope from the left and from the right differ by
+/// more than `SM_EFF_ZERO * (1 + |left| + |right|)`. The slope is the derivative B-spline's,
+/// `Q_i = p (r_{i+1} - r_i) / (u_{i+p+1} - u_{i+1})`; it jumps only at a knot repeated `m >= p`
+/// times, from `Q_{s-2}` to `Q_{s+m-1-p}` (`s` the knot's first index).
+fn law_has_corner(degree: usize, knots: &[f64], radii: &[f64]) -> bool {
+ const EFF_ZERO: f64 = 1e-12;
+ let p = degree;
+ let q = |i: usize| -> Option {
+ let (r0, r1) = (radii.get(i)?, radii.get(i + 1)?);
+ let span = knots.get(i + p + 1)? - knots.get(i + 1)?;
+ Some(p as f64 * (r1 - r0) / span)
+ };
+ let n = radii.len();
+ let mut s = p + 1;
+ while s < n {
+ let Some(&u) = knots.get(s) else { break };
+ let mut m = 1;
+ while knots.get(s + m) == Some(&u) {
+ m += 1;
+ }
+ if m >= p && u > 0.0 && u < 1.0 {
+ let (Some(left), Some(right)) = (s.checked_sub(2).and_then(q), q(s + m - 1 - p)) else {
+ return true;
+ };
+ if (left - right).abs() > EFF_ZERO * (1.0 + left.abs() + right.abs()) {
+ return true;
+ }
+ }
+ s += m;
+ }
+ false
+}
+
+/// `law_along`: the radius law along `e`, the B-spline with the radius as its x and its own
+/// parameter as its y (control points at the Greville abscissae), reversed when `start` is
+/// nearer the end of the edge's interval than its start. `None` where the capi's is null.
+fn law_along(k: &Kernel, b: &Brep, e: EdgeId, start: [f64; 3], degree: usize, knots: &[f64], radii: &[f64]) -> solidcore::Result> {
+ let edge = b.edge(e)?;
+ let Some(curve) = edge.curve.as_ref() else { return Ok(None) };
+ let ivl = edge.interval;
+ let (Some(lo), Some(hi)) = (answer(curve.evaluate_point(k, ivl.min()))?, answer(curve.evaluate_point(k, ivl.max()))?) else {
+ return Ok(None);
+ };
+ let at = p3(start);
+ let reverse = (hi - at).length() < (lo - at).length();
+ let (nk, nr) = (knots.len(), radii.len());
+ let kv: Vec = (0..nk).map(|i| if reverse { 1.0 - knots[nk - 1 - i] } else { knots[i] }).collect();
+ let mut cps = Vec::with_capacity(nr);
+ for i in 0..nr {
+ let g: f64 = (1..=degree).filter_map(|j| kv.get(i + j)).sum();
+ let r = if reverse { radii[nr - 1 - i] } else { radii[i] };
+ cps.push(Vec3::new(r, g / degree as f64, 0.0));
+ }
+ let (mut unique, mut mults): (Vec, Vec) = (Vec::new(), Vec::new());
+ for &x in &kv {
+ match (unique.last(), mults.last_mut()) {
+ (Some(&u), Some(m)) if u == x => *m += 1,
+ _ => {
+ unique.push(x);
+ mults.push(1);
+ }
+ }
+ }
+ let data = CanonicalData {
+ dim: 3,
+ degree,
+ control_points: &cps,
+ form: BSplineCurveForm::Unspecified,
+ knot_multiplicities: &mults,
+ knots: &unique,
+ knot_type: KnotType::Unspecified,
+ weights: None,
+ trim: None,
+ };
+ let Some(c) = answer(BSplineCurve::create_canonical(&data))? else { return Ok(None) };
+ // The law's map is the whole curve, 0 to 1 (`FilletLaw::new`'s).
+ Ok(Some(FilletLaw::new(FilletLawKind::BSpline { law_curve: c, extended_law: None })))
+}
+
+/// `smlib_fillet_variable`: the one edge nearest `point` rounded with the radius law of
+/// `degree`, `knots` and `radii`, 0 at the end nearest `start`.
+pub(crate) fn fillet_variable(
+ body: &Body,
+ point: [f64; 3],
+ tolerance: f64,
+ start: [f64; 3],
+ degree: usize,
+ knots: &[f64],
+ radii: &[f64],
+) -> Result {
+ if !points_ok(&[point], false) || !start.iter().all(|x| x.is_finite()) || !tolerance_ok(tolerance) || !law_ok(degree, knots, radii) {
+ return Err(Error::Argument);
+ }
+ if law_pinches(degree, knots, radii) {
+ return Err(Error::Unsupported);
+ }
+ on_copy("smlib_fillet_variable", body, |k, mut w| {
+ let picked = match pick_edges(k, &w, &[point], tolerance)? {
+ Ok((p, _)) => p,
+ Err(m) => return Ok(Err(edge_miss(m))),
+ };
+ let [e] = picked[..] else { return Ok(Err(Error::NoMatch { point: 0 })) };
+ let Some(law) = law_along(k, &w, e, start, degree, knots, radii)? else {
+ return Ok(Err(Error::Status(INVALID_INPUT)));
+ };
+ // A law with a corner splits the round's face there (`SplitFilletFacesAtRadiusCorners`,
+ // fork 6eecf04), which solidcore does not have.
+ if law_has_corner(degree, knots, radii) {
+ return Ok(Err(Error::Unsupported));
+ }
+ // The round is the union of its balls (smlib#78, solidcore#2262 with
+ // `fillet_gn_section_generator`): each ball touches it along the circle shifted
+ // -r r'/|c'| from its great circle. A law whose balls fold over one another is refused
+ // (`NoRoomReason::BallsFold`), [`Error::NoRoom`].
+ let solid_in = brep_p2::is_manifold_solid(&w)?;
+ let (r0, r1) = (radii.first().copied().unwrap_or(0.0), radii.last().copied().unwrap_or(0.0));
+ Ok(match blend_edges(k, &mut w, &[e], &[r0.max(r1)], Section::Round, Some(law), &[], None)? {
+ Ok(()) => finish(w, solid_in)?,
+ Err(Ok(_)) => Err(Error::Unsupported),
+ Err(Err(e)) => Err(e),
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn laws_are_checked_as_the_capi_checks_them() {
+ assert!(law_ok(1, &[0.0, 0.0, 1.0, 1.0], &[1.0, 2.0]));
+ assert!(!law_ok(1, &[0.0, 0.0, 1.0], &[1.0, 2.0]));
+ assert!(!law_ok(1, &[0.0, 0.5, 1.0, 1.0], &[1.0, 2.0]));
+ assert!(!law_ok(1, &[0.0, 0.0, 1.0, 1.0], &[-1.0, 2.0]));
+ assert!(!law_ok(4, &[0.0; 7], &[1.0, 2.0]));
+ assert!(law_pinches(1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 0.0, 1.0]));
+ assert!(!law_pinches(1, &[0.0, 0.0, 1.0, 1.0], &[0.0, 1.0]));
+ // A straight law has no corner; a bent one does at its inner knot; a cubic none.
+ assert!(!law_has_corner(1, &[0.0, 0.0, 1.0, 1.0], &[1.0, 2.0]));
+ assert!(!law_has_corner(1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 1.5, 2.0]));
+ assert!(law_has_corner(1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 2.0, 1.0]));
+ assert!(!law_has_corner(3, &[0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], &[1.0, 2.0, 1.0, 2.0, 1.0]));
+ }
+
+ #[test]
+ fn picks_follow_the_capis_rule() {
+ let all = [0.0f64, 1.0, 3.0];
+ let d = |x: f64, p: Point3| Ok(Some((x - p.x).abs()));
+ assert_eq!(pick(&all, &[[0.9, 0.0, 0.0]], 0.5, 1e-6, d).unwrap(), Ok((vec![1.0], vec![0])));
+ assert_eq!(pick(&all, &[[2.0, 0.0, 0.0]], 0.5, 1e-6, d).unwrap(), Err(Miss::None(0)));
+ assert_eq!(pick(&all, &[[1.0, 0.0, 0.0], [0.5, 0.0, 0.0]], 1.0, 1e-6, d).unwrap(), Err(Miss::Ambiguous(1)));
+ // The same entity twice is picked once, by its first point.
+ assert_eq!(pick(&all, &[[3.1, 0.0, 0.0], [2.9, 0.0, 0.0]], 0.5, 1e-6, d).unwrap(), Ok((vec![3.0], vec![0])));
+ }
+}
diff --git a/crates/smlib/src/core/blend_tests.rs b/crates/smlib/src/core/blend_tests.rs
new file mode 100644
index 0000000000..e03e1e2312
--- /dev/null
+++ b/crates/smlib/src/core/blend_tests.rs
@@ -0,0 +1,1051 @@
+//! Fillets and chamfers 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, 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 and cylinders.
+
+use std::collections::BTreeMap;
+use std::f64::consts::PI;
+use std::sync::mpsc;
+use std::time::Duration;
+
+use super::blend::{Corners, blend, blend_dropping, blend_holes, chamfer_sides, fillet_variable};
+use super::boolean::boolean;
+use super::{Body, counts, make_box, make_cylinder, to_arrays, volume};
+use crate::export::native;
+use crate::{Blend, BooleanOp, ChamferSecond, 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),
+ /// A box (corner, size) less a cylinder along z (base centre, radius, height).
+ Holed([f64; 3], [f64; 3], [f64; 3], f64, 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::Holed(o, s, c, r, h) => {
+ boolean(&make_box(o, s).unwrap(), &make_cylinder(c, r, h).unwrap(), BooleanOp::Subtract).map(|b| b.unwrap())
+ }
+ }
+ .unwrap()
+ }
+}
+
+#[derive(Clone, Debug)]
+enum Op {
+ /// `Brep::blend` of one kind, one size for every edge.
+ Blend(Shape, Blend, Vec<[f64; 3]>, f64),
+ /// `Brep::chamfer_sides`.
+ Sides(Shape, Vec<[f64; 3]>, f64, ChamferSecond, [f64; 3]),
+ /// `Brep::blend_setback` of rounds: edges, radius, corners, setbacks.
+ Setback(Shape, Vec<[f64; 3]>, f64, Vec<[f64; 3]>, Vec),
+ /// `Brep::fillet_variable`: edge point, start, degree, knots, radii.
+ Variable(Shape, [f64; 3], [f64; 3], usize, Vec, Vec),
+}
+
+/// What a case must give.
+#[derive(Clone, Debug)]
+enum Expect {
+ /// A body of this volume (relative 1e-9).
+ Volume(f64),
+ /// A body of this volume to this relative accuracy.
+ Near(f64, f64),
+ /// A body whose volume lies strictly between these.
+ Between(f64, f64),
+ /// 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;
+/// Its top front edge, along x at y = 0, z = 10, 30 long.
+const TOP_FRONT: [f64; 3] = [15.0, 0.0, 10.0];
+
+/// The cross-section a round of radius r takes from a right-angled edge: (1 - π/4) r².
+fn spandrel(r: f64) -> f64 {
+ (1.0 - PI / 4.0) * r * r
+}
+
+fn case(name: &str) -> Case {
+ let c = |op, expect| Case { op, expect };
+ let r = 2.0;
+ match name {
+ // abc - (1 - π/4) r² L.
+ "fillet_one_edge" => c(Op::Blend(BLOCK, Blend::Round, vec![TOP_FRONT], r), Expect::Volume(ABC - spandrel(r) * 30.0)),
+ // Every edge rounded at r, the corners spheres: (a-2r)(b-2r)(c-2r) + 2r Σ(faces less
+ // 2r) + πr² Σ(edges less 2r) + 4/3 πr³.
+ "fillet_all_edges" => {
+ let (a, b, h) = (30.0 - 2.0 * r, 20.0 - 2.0 * r, 10.0 - 2.0 * r);
+ let v = a * b * h + 2.0 * r * (a * b + b * h + h * a) + PI * r * r * (a + b + h) + 4.0 / 3.0 * PI * r * r * r;
+ let mut edges = Vec::new();
+ for (y, z) in [(0.0, 0.0), (20.0, 0.0), (0.0, 10.0), (20.0, 10.0)] {
+ edges.push([15.0, y, z]);
+ }
+ for (x, z) in [(0.0, 0.0), (30.0, 0.0), (0.0, 10.0), (30.0, 10.0)] {
+ edges.push([x, 10.0, z]);
+ }
+ for (x, y) in [(0.0, 0.0), (30.0, 0.0), (0.0, 20.0), (30.0, 20.0)] {
+ edges.push([x, y, 5.0]);
+ }
+ c(Op::Blend(BLOCK, Blend::Round, edges, r), Expect::Volume(v))
+ }
+ // The curvature section's default thumbweight is equal area (fork 6148773): on the
+ // round's rails it takes off what the ball's circle does, abc - (1 - π/4) r² L.
+ "fillet_curvature_one_edge" => {
+ c(Op::Blend(BLOCK, Blend::Curvature, vec![TOP_FRONT], r), Expect::Volume(ABC - (1.0 - PI / 4.0) * r * r * 30.0))
+ }
+ // abc - d² L / 2.
+ "chamfer_one_edge" => c(Op::Blend(BLOCK, Blend::Chamfer, vec![TOP_FRONT], r), Expect::Volume(ABC - r * r * 30.0 / 2.0)),
+ // abc - d1 d2 L / 2: 2 back along the top (the face leaning most along +z), 3 down the
+ // front.
+ "chamfer_two_distances" => {
+ c(Op::Sides(BLOCK, vec![TOP_FRONT], 2.0, ChamferSecond::Distance(3.0), [0.0, 0.0, 1.0]), Expect::Volume(ABC - 2.0 * 3.0 * 30.0 / 2.0))
+ }
+ // 2 back along the top, at 30° to it: d tan 30° down the front.
+ "chamfer_distance_angle" => c(
+ Op::Sides(BLOCK, vec![TOP_FRONT], 2.0, ChamferSecond::Angle(PI / 6.0), [0.0, 0.0, 1.0]),
+ Expect::Volume(ABC - 2.0 * 2.0 * (PI / 6.0).tan() * 30.0 / 2.0),
+ ),
+ // A 5 mm cylinder 10 high, its top rim rounded at 1. Pappus: the spandrel (1 - π/4) r²,
+ // its centroid (10 - 3π)/(12 - 3π) r in from the rim, swept round the axis.
+ "fillet_cylinder_rim" => {
+ let (big, h, r) = (5.0, 10.0, 1.0);
+ let inset = (10.0 - 3.0 * PI) / (12.0 - 3.0 * PI) * r;
+ c(
+ Op::Blend(Shape::Cylinder([0.0; 3], big, h), Blend::Round, vec![[0.0, big, h]], r),
+ Expect::Volume(PI * big * big * h - 2.0 * PI * (big - inset) * spandrel(r)),
+ )
+ }
+ // The three edges at the corner (30, 20, 10) rounded at r, the corner set back 4 along
+ // each. Without a setback the corner is a sphere's octant, and the block keeps
+ // abc - (1 - π/4) r² Σ(L - r) - (1 - π/6) r³; the setback changes only what lies within
+ // s + r of the corner, a cube of side s + r.
+ "setback_corner" => {
+ let s = 4.0;
+ let plain = ABC - spandrel(r) * (30.0 + 20.0 + 10.0 - 3.0 * r) - (1.0 - PI / 6.0) * r * r * r;
+ let near = (s + r) * (s + r) * (s + r);
+ c(
+ Op::Setback(BLOCK, vec![[15.0, 20.0, 10.0], [30.0, 10.0, 10.0], [30.0, 20.0, 5.0]], r, vec![[30.0, 20.0, 10.0]], vec![s]),
+ Expect::Between(plain - near, plain + near),
+ )
+ }
+ // The top front edge rounded from 1 at x = 0 to 3 at x = 30, linearly, each section
+ // square to the edge a quarter circle: abc - (1 - π/4) ∫ r(x)² dx
+ // = abc - (1 - π/4) L (r0² + r0 r1 + r1²) / 3. The round is a B-spline surface fitted to
+ // those sections, which both kernels build 2.5e-7 short of it (and within 1e-9 of each
+ // other), within the solver's fitting tolerance.
+ // The union of the balls, r from 1 to 3 along the 30 mm edge: in each plane across the
+ // edge, the area between the corner and where each ray from it first enters a ball
+ // (Gauss-Legendre in the ray's angle, Simpson along the edge, converged to 1e-8 of the
+ // body), 27.857434; the balls' great circles would take (1 - π/4) L (r0² + r0 r1 + r1²) / 3,
+ // 27.898239.
+ "fillet_variable_linear" => {
+ c(Op::Variable(BLOCK, TOP_FRONT, [0.0, 0.0, 10.0], 1, vec![0.0, 0.0, 1.0, 1.0], vec![1.0, 3.0]), Expect::Near(ABC - 27.857434, 1e-6))
+ }
+ // A round of 12 on a 10 mm cube's top front edge is larger than either face beside it:
+ // the fork builds it over the cliffs, on the faces' continuations, and cuts away all of
+ // the cube outside the round's cylinder (axis y = 12, z = -2, radius 12). What stays is
+ // 10 times the square's part inside that circle, ∫ (√(144 - u²) - 2) du over
+ // u = y - 12 from -√140 to -2: 72 (asin(√140/12) - asin(1/6)) - 2 (√140 - 2).
+ "fillet_over_a_cliff" => {
+ let w = 140f64.sqrt();
+ let area = 72.0 * ((w / 12.0).asin() - (1.0f64 / 6.0).asin()) - 2.0 * (w - 2.0);
+ c(Op::Blend(Shape::Box([0.0; 3], [10.0; 3]), Blend::Round, vec![[5.0, 0.0, 10.0]], 12.0), Expect::Volume(10.0 * area))
+ }
+ // A round of 6 on the top rim of a cylinder of radius 5 is wider than the cylinder.
+ "fillet_too_large" => {
+ c(Op::Blend(Shape::Cylinder([0.0; 3], 5.0, 10.0), Blend::Round, vec![[0.0, 5.0, 10.0]], 6.0), Expect::Fails(Error::NoRoom))
+ }
+ "pick_near_nothing" => {
+ c(Op::Blend(BLOCK, Blend::Round, vec![TOP_FRONT, [100.0, 100.0, 100.0]], r), Expect::Fails(Error::NoMatch { point: 1 }))
+ }
+ // A 20 mm cube with a hole of radius 5 along z whose wall touches the face y = 0 along
+ // x = 10 (#1390): a 3 mm chamfer of the hole's top rim runs out of that face where the
+ // rim meets it tangentially, a vertex no blend corner handles. The tangent chain the app
+ // blends is picked: the rim at 135° and 315° round the hole (away from the cylinder's
+ // seam at 0° and the face at 270°), and the two pieces of the top's front edge, which the
+ // rim runs on tangentially into at (10, 0, 20).
+ "chamfer_hole_rim_touching_a_face" => {
+ let d = 5.0 * std::f64::consts::FRAC_1_SQRT_2;
+ c(
+ Op::Blend(
+ Shape::Holed([0.0; 3], [20.0; 3], [10.0, 5.0, -1.0], 5.0, 22.0),
+ Blend::Chamfer,
+ vec![[10.0 - d, 5.0 + d, 20.0], [10.0 + d, 5.0 - d, 20.0], [5.0, 0.0, 20.0], [15.0, 0.0, 20.0]],
+ 3.0,
+ ),
+ Expect::Fails(Error::CornerUnsupported),
+ )
+ }
+ // √3 from all three edges at the corner (0, 0, 10).
+ "pick_ambiguous" => c(Op::Blend(BLOCK, Blend::Round, vec![[-1.0, -1.0, 11.0]], r), Expect::Fails(Error::Ambiguous { point: 0 })),
+ _ => panic!("{name}"),
+ }
+}
+
+/// The tolerance a case picks with: the ambiguous point lies √3 from its edges.
+fn tolerance(name: &str) -> f64 {
+ if name == "pick_ambiguous" { 5.0 } else { TOL }
+}
+
+fn rel(a: f64, b: f64) -> f64 {
+ (a - b).abs() / b.abs()
+}
+
+#[derive(Debug, PartialEq)]
+struct Measured {
+ volume: f64,
+ counts: [usize; 3],
+ kinds: BTreeMap,
+}
+
+fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap {
+ 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(name: &str, c: &Case) -> crate::Result {
+ let tol = tolerance(name);
+ match &c.op {
+ Op::Blend(s, kind, points, size) => blend(&s.core(), *kind, points, &[*size], tol, &[], &[], Corners::Filled),
+ Op::Sides(s, points, d, second, first) => chamfer_sides(&s.core(), points, tol, *d, *second, *first),
+ Op::Setback(s, points, size, corners, setbacks) => {
+ blend(&s.core(), Blend::Round, points, &vec![*size; points.len()], tol, corners, setbacks, Corners::Filled)
+ }
+ Op::Variable(s, point, start, degree, knots, radii) => fillet_variable(&s.core(), *point, tol, *start, *degree, knots, radii),
+ }
+}
+
+/// Runs `f` on its own thread: a hang fails the test instead of stopping the run.
+fn timed(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;
+
+/// 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.volume, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.volume),
+ (Expect::Near(v, within), Ok(m)) => assert!(rel(m.volume, *v) < *within, "{name}: {who} volume {} vs analytic {v}", m.volume),
+ (Expect::Between(lo, hi), Ok(m)) => assert!(*lo < m.volume && m.volume < *hi, "{name}: {who} volume {} not in ({lo}, {hi})", m.volume),
+ }
+ got
+}
+
+fn solidcore_case(name: &'static str) -> Got {
+ timed(name, move || {
+ let c = case(name);
+ let got = on_core(name, &c).map(|body| {
+ let v = volume(&body, 1e-8).unwrap_or_else(|e| panic!("{name}: volume: {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}"));
+ Measured { volume: v, 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, setback_error};
+ 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::Holed(o, z, c, r, h) => {
+ ffi::ops::boolean(&ffi::make_box(&o, &z).unwrap(), &ffi::make_cylinder(&c, r, h).unwrap(), ffi::ops::BOOLEAN_SUBTRACT)
+ }
+ }
+ .map(Brep::made)
+ .unwrap()
+ };
+ let kind = |k| match k {
+ 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 c = case(name);
+ let tol = tolerance(name);
+ let r = match &c.op {
+ Op::Blend(s, k, points, size) => {
+ ffi::ops::blend(kind(*k), make(*s).cpp().unwrap(), points, &vec![*size; points.len()], tol).map_err(pick_error)
+ }
+ Op::Sides(s, points, d, second, first) => {
+ let (value, angle) = match second {
+ ChamferSecond::Distance(d) => (*d, false),
+ ChamferSecond::Angle(a) => (*a, true),
+ };
+ ffi::ops::chamfer_sides(make(*s).cpp().unwrap(), points, tol, *d, value, angle, first).map_err(pick_error)
+ }
+ Op::Setback(s, points, size, corners, setbacks) => {
+ ffi::ops::blend_setback(ffi::ops::BLEND_ROUND, make(*s).cpp().unwrap(), points, &vec![*size; points.len()], corners, setbacks, tol)
+ .map_err(setback_error)
+ }
+ Op::Variable(s, point, start, degree, knots, radii) => {
+ ffi::ops::fillet_variable(make(*s).cpp().unwrap(), point, tol, start, i32::try_from(*degree).unwrap(), knots, radii).map_err(
+ |s| match s {
+ ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 },
+ ffi::ops::ERR_PICK_AMBIGUOUS => Error::Ambiguous { point: 0 },
+ s => crate::error(s),
+ },
+ )
+ }
+ };
+ let got = r.map(|h| {
+ let v = ffi::volume(&h, 1e-8).unwrap();
+ let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap());
+ let b = Brep::made(h);
+ let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap();
+ rec.validate().unwrap();
+ Measured { volume: v, counts: n, kinds: kinds(&rec) }
+ });
+ judge(name, "C++", &c.expect, got)
+}
+
+/// solidcore and C++ agree: volume 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.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++)");
+ }
+ (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!(
+ fillet_one_edge,
+ fillet_all_edges,
+ fillet_curvature_one_edge,
+ chamfer_one_edge,
+ chamfer_two_distances,
+ chamfer_distance_angle,
+ fillet_cylinder_rim,
+ setback_corner,
+ fillet_variable_linear,
+ fillet_over_a_cliff,
+ fillet_too_large,
+ chamfer_hole_rim_touching_a_face,
+ pick_near_nothing,
+ pick_ambiguous,
+);
+
+/// What solidcore does not have yet is refused as not supported, after the capi's own checks.
+#[test]
+fn fork_only_blends_are_not_supported_yet() {
+ let r = timed("unsupported", || {
+ let b = BLOCK.core();
+ // 1 to 2 and back to 1: a corner at the middle.
+ let bent = fillet_variable(&b, TOP_FRONT, TOL, [0.0, 0.0, 10.0], 1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 2.0, 1.0]).err();
+ // A pick that fails still says so first.
+ let far = chamfer_sides(&b, &[[100.0; 3]], TOL, 2.0, ChamferSecond::Distance(3.0), [0.0, 0.0, 1.0]).err();
+ (bent, far)
+ });
+ assert_eq!(r.0, Some(Error::Unsupported));
+ assert_eq!(r.1, Some(Error::NoMatch { point: 0 }));
+}
+
+/// A cliff overflow the fork refuses too (`fixes.fillet_cliff_overflow`, solidcore#960): the
+/// three edges at a 20 mm cube's corner rounded r20, their fillet's boundary on two faces'
+/// surfaces, has no room, as on the fork, not "not supported".
+#[test]
+fn a_cliff_overflow_the_fork_refuses_has_no_room() {
+ let r = timed("cliff_refused", || {
+ let corner = [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]];
+ blend(&Shape::Box([0.0; 3], [20.0; 3]).core(), Blend::Round, &corner, &[20.0; 3], TOL, &[], &[], Corners::Filled).err()
+ });
+ assert_eq!(r, Some(Error::NoRoom));
+}
+
+/// The capi's `overflow_goes_first` (fork 241f7ba): R21 on a 20 cube's edge takes all of both faces
+/// that edge lies on, and rounds with it on other edges go after it. With R5 on the corner's other
+/// two edges those edges are gone (the two R5 points flagged) and the result is the R21's alone,
+/// 20 (F(w) - F(1) - (w - 1)) with F the integral of sqrt(441 - u²) and w = sqrt(440). With R5 on
+/// the back upright that upright's round runs above the R21 cylinder, taking the corner's piece
+/// over it too (Simpson on 4000 spans, against a bound of its quartic end edge's tolerance).
+#[test]
+fn overflow_goes_first() {
+ #[cfg(feature = "native")]
+ let _cpp = crate::route::on_cpp();
+ let f = |u: f64| 0.5 * (u * (441.0 - u * u).sqrt() + 441.0 * (u / 21.0).asin());
+ let w = 440f64.sqrt();
+ let alone = 20.0 * (f(w) - f(1.0) - (w - 1.0));
+ let strip = |t: f64| {
+ let y = 15.0 + 5.0 * t.sin();
+ let zc = 21.0 - (441.0 - (y - 21.0) * (y - 21.0)).sqrt();
+ 5.0 * (1.0 - t.cos()) * (20.0 - zc) * 5.0 * t.cos()
+ };
+ let (n, h) = (4000, 0.5 * PI / 4000.0);
+ let mut sum = strip(0.0) + strip(0.5 * PI);
+ for i in 1..n {
+ sum += f64::from(if i % 2 == 1 { 4 } else { 2 }) * strip(f64::from(i) * h);
+ }
+ let corner = sum * h / 3.0;
+ assert!((corner - 106.612_300_588).abs() < 1e-6, "{corner}");
+ let cases: [(&[[f64; 3]], &[f64], &[bool], f64, f64); 2] = [
+ (&[[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]], &[21.0, 5.0, 5.0], &[false, true, true], alone, 1e-9 * alone),
+ (&[[10.0, 0.0, 0.0], [0.0, 20.0, 10.0]], &[21.0, 5.0], &[false, false], alone - corner, 0.02),
+ ];
+ for (points, sizes, dropped, want, bound) in cases {
+ let got = timed("overflow_first", move || {
+ let (b, d) = blend_dropping(&Shape::Box([0.0; 3], [20.0; 3]).core(), Blend::Round, points, sizes, 1.0).unwrap();
+ native(&to_arrays(&b).unwrap()).unwrap();
+ (volume(&b, 1e-8).unwrap(), d)
+ });
+ println!("overflow first {sizes:?}: {:.9}, expected {want:.9}", got.0);
+ assert_eq!(got.1, dropped, "{sizes:?}");
+ assert!((got.0 - want).abs() <= bound, "{sizes:?}: {} vs {want}", got.0);
+ #[cfg(feature = "native")]
+ {
+ let body = crate::Brep::made_core(Shape::Box([0.0; 3], [20.0; 3]).core());
+ let (cpp, d) = crate::ffi::ops::blend_dropping(crate::ffi::ops::BLEND_ROUND, body.cpp().unwrap(), points, sizes, 1.0).unwrap();
+ assert_eq!(d, dropped, "C++ {sizes:?}");
+ let v = crate::ffi::volume(&cpp, 1e-8).unwrap();
+ assert!((got.0 - v).abs() <= bound, "{sizes:?}: solidcore {} vs C++ {v}", got.0);
+ }
+ }
+}
+
+/// The capi's `rounds_at_a_concave_face_corner_are_mitred` (`fixes.fillet_mitre_concave_face_corners`,
+/// fork 4f06e72): each edge loses (convex) or gains (concave) the spandrel r²(1 - π/4) along its
+/// length, and in the quadrant beyond both walls the mitred rounds take or add r³(5/3 - π/2): a
+/// 60 x 40 x 10 plate's 30 x 20 x 5 pocket rim at r2 (15 faces) and r5 (11), a 60 x 60 x 5 plate's
+/// 20 x 20 x 15 boss root at r4 (15). And the pocket rim chamfered 5.5, past the pocket's depth:
+/// 21000 - (50 d² + 4/3 d³).
+#[test]
+fn rounds_at_a_concave_face_corner_are_mitred() {
+ let made = |a: Body, b: Body, op| boolean(&a, &b, op).unwrap().unwrap();
+ let pocket =
+ || made(make_box([0.0; 3], [60.0, 40.0, 10.0]).unwrap(), make_box([15.0, 10.0, 5.0], [30.0, 20.0, 6.0]).unwrap(), BooleanOp::Subtract);
+ let boss = || made(make_box([0.0; 3], [60.0, 60.0, 5.0]).unwrap(), make_box([20.0, 20.0, 5.0], [20.0, 20.0, 15.0]).unwrap(), BooleanOp::Union);
+ let rim = [[30.0, 10.0, 10.0], [30.0, 30.0, 10.0], [15.0, 20.0, 10.0], [45.0, 20.0, 10.0]];
+ let root = [[30.0, 20.0, 5.0], [40.0, 30.0, 5.0], [30.0, 40.0, 5.0], [20.0, 30.0, 5.0]];
+ let want = |v0: f64, length: f64, r: f64, sign: f64| v0 + sign * (length * r * r * (1.0 - PI / 4.0) + 4.0 * r * r * r * (5.0 / 3.0 - PI / 2.0));
+ for (body, edges, r, v, faces) in [
+ (pocket(), rim, 2.0, want(21000.0, 100.0, 2.0, -1.0), 15),
+ (pocket(), rim, 5.0, want(21000.0, 100.0, 5.0, -1.0), 11),
+ (boss(), root, 4.0, want(24000.0, 80.0, 4.0, 1.0), 15),
+ ] {
+ let b = blend(&body, Blend::Round, &edges, &[r], 0.1, &[], &[], Corners::Filled).unwrap();
+ native(&to_arrays(&b).unwrap()).unwrap();
+ let got = volume(&b, 1e-12).unwrap();
+ println!("mitred r{r}: {} faces, {got:.9}, expected {v:.9}", counts(&b).unwrap()[0]);
+ assert_eq!(counts(&b).unwrap()[0], faces, "r{r}");
+ assert!(rel(got, v) < 2e-8, "r{r}: {got} vs {v}");
+ }
+ // Through the flat arrays, as bodies reach a blend in the app (pocket_chamfer_on_a_boolean_body).
+ let d = 5.5;
+ let flat = super::from_arrays(&to_arrays(&pocket()).unwrap()).unwrap();
+ let b = blend(&flat, Blend::Chamfer, &rim, &[d], 0.1, &[], &[], Corners::Filled).unwrap();
+ let (got, v) = (volume(&b, 1e-8).unwrap(), 21000.0 - (50.0 * d * d + 4.0 / 3.0 * d * d * d));
+ println!("mitred chamfer {d}: {got:.9}, expected {v:.9}");
+ assert!(rel(got, v) < 1e-8, "{got} vs {v}");
+}
+
+/// Solidcore alone: edge attributes across a cliff overflow. A 40 x 30 x 10 block notched below its
+/// top at (40, 0), its front and right top edges rounded r8 (their rails run off over the notch).
+/// The back and left top edges are each trimmed 8 by those rounds: (0, 30)-(32, 30) and (0, 8)-(0, 30)
+/// at z = 10, and each piece keeps its edge's attribute (`Behavior::Copy`), as on the fork.
+#[test]
+fn an_edge_tag_crosses_a_cliff_overflow() {
+ use solidcore::blend::fillet_executive::session::FilletSession;
+ use solidcore::boolean::api_brep::boolean_difference;
+ use solidcore::geom::attribute::{Attribute, Behavior};
+ use solidcore::primitive::api_primitives::create_box;
+ use solidcore::topology::{Brep, EdgeId, edge, vertex};
+ use solidcore::{EntityRef, Kernel, Point3};
+ fn between(b: &Brep, a: Point3, q: Point3) -> Option {
+ b.edges().ids().find(|&e| {
+ let (s, t) = edge::start_end_vertices(b, e).unwrap();
+ let (s, t) = (vertex::point(b, s.unwrap()).unwrap(), vertex::point(b, t.unwrap()).unwrap());
+ (s.distance(a) < 1e-6 && t.distance(q) < 1e-6) || (s.distance(q) < 1e-6 && t.distance(a) < 1e-6)
+ })
+ }
+ let p = Point3::new;
+ let mut k = Kernel::default();
+ k.fixes.fillet_refuse_rails_leaving_faces = true;
+ k.fixes.fillet_trace_reaches_rails = true;
+ k.fixes.fillet_rail_end_on_continued_surface = true;
+ k.fixes.fillet_cliff_overflow = true;
+ let block = create_box(&mut k, Point3::ZERO, 40.0, 30.0, 10.0).unwrap();
+ let notch = create_box(&mut k, p(35.0, -1.0, -1.0), 6.0, 6.0, 4.0).unwrap();
+ let mut b = boolean_difference(&mut k, block, notch).unwrap();
+ const ID: u64 = 0x7000_0001;
+ let edge_at = |b: &Brep, a, q| between(b, a, q).unwrap();
+ for (a, q, v) in [(p(0.0, 30.0, 10.0), p(40.0, 30.0, 10.0), 1), (p(0.0, 0.0, 10.0), p(0.0, 30.0, 10.0), 2)] {
+ let e = edge_at(&b, a, q);
+ b.add_attribute(EntityRef::Edge(e), Attribute::long(ID, v, Behavior::Copy)).unwrap();
+ }
+ let rounds = [edge_at(&b, p(0.0, 0.0, 10.0), p(40.0, 0.0, 10.0)), edge_at(&b, p(40.0, 0.0, 10.0), p(40.0, 30.0, 10.0))];
+ let mut s = FilletSession::new(&mut k, &mut b).unwrap();
+ s.prepare_constant_radius_edges(&mut k, &rounds, 8.0, 1, 1, 1.0).unwrap();
+ s.do_fillet(&mut k).unwrap();
+ drop(s);
+ b.refresh_cached_topology();
+ for (a, q, which) in [(p(0.0, 30.0, 10.0), p(32.0, 30.0, 10.0), "back"), (p(0.0, 8.0, 10.0), p(0.0, 30.0, 10.0), "left")] {
+ let e = edge_at(&b, a, q);
+ assert!(b.entity_attributes(EntityRef::Edge(e)).iter().any(|(_, x)| x.attribute_id() == ID), "the {which} edge lost its attribute");
+ }
+}
+
+/// Solidcore alone: the pocket rim chamfered 5.5, as `fillet_mitre_concave_face_corners`' own test
+/// does it, with `keep_operand_uv_trims` on too, on the body the boolean gives:
+/// 21000 - (50 d² + 4/3 d³) = 19265.666666667 (the fork, and the same body through the flat
+/// arrays). Without `fillet_run_on_bspline_trims` it is refused: error 3, Corner Edge Computation
+/// Failure (solidcore#1981).
+#[test]
+fn pocket_chamfer_on_a_boolean_body() {
+ use solidcore::blend::fillet_executive::session::FilletSession;
+ use solidcore::boolean::api_brep::boolean_difference;
+ use solidcore::primitive::api_primitives::create_box;
+ use solidcore::topology::{EdgeId, edge, vertex};
+ use solidcore::{Kernel, Point3};
+ let p = Point3::new;
+ let mut k = Kernel::default();
+ k.fixes.fillet_refuse_rails_leaving_faces = true;
+ k.fixes.fillet_trace_reaches_rails = true;
+ k.fixes.fillet_rail_end_on_continued_surface = true;
+ k.fixes.fillet_cliff_overflow = true;
+ k.fixes.fillet_mitre_concave_face_corners = true;
+ k.fixes.keep_operand_uv_trims = true;
+ k.fixes.fillet_run_on_bspline_trims = true;
+ let plate = create_box(&mut k, Point3::ZERO, 60.0, 40.0, 10.0).unwrap();
+ let cut = create_box(&mut k, p(15.0, 10.0, 5.0), 30.0, 20.0, 6.0).unwrap();
+ let mut b = boolean_difference(&mut k, plate, cut).unwrap();
+ let c = [p(15.0, 10.0, 10.0), p(45.0, 10.0, 10.0), p(45.0, 30.0, 10.0), p(15.0, 30.0, 10.0)];
+ let on = |x: Point3, a: Point3, q: Point3| x.distance(a) < 1e-9 || x.distance(q) < 1e-9;
+ let ids: Vec = (0..4)
+ .map(|i| {
+ let (a, q) = (c[i], c[(i + 1) % 4]);
+ b.edges()
+ .ids()
+ .find(|&e| {
+ let (s, t) = edge::start_end_vertices(&b, e).unwrap();
+ on(vertex::point(&b, s.unwrap()).unwrap(), a, q) && on(vertex::point(&b, t.unwrap()).unwrap(), a, q) && s != t
+ })
+ .unwrap()
+ })
+ .collect();
+ let mut s = FilletSession::new(&mut k, &mut b).unwrap();
+ s.prepare_constant_radius_edges(&mut k, &ids, 5.5, 0, 1, 1.0).unwrap();
+ let r = s.do_fillet(&mut k);
+ assert!(r.is_ok(), "{} {}", s.error_info().code, s.error_info().comment);
+ drop(s);
+ b.refresh_cached_topology();
+ let v = volume(&Body::new(b), 1e-8).unwrap();
+ let d: f64 = 5.5;
+ assert!(rel(v, 21000.0 - (50.0 * d * d + 4.0 / 3.0 * d * d * d)) < 1e-8, "{v}");
+}
+
+/// The corner of r5 rounds at (60, 40, 20) on a 60 x 40 x 20 box, set back 8 and left open: the
+/// box's six faces and the three rounds (9 faces, 30 edges, 22 vertices), a sheet, and one hole
+/// of twelve edges (each section and face curve in two), chained end to start, each beside a face
+/// of the sheet. With SMLib built, the C++ kernel gives the same counts and the same hole.
+#[test]
+fn a_setback_corner_left_open_returns_its_hole() {
+ #[cfg(feature = "native")]
+ let _cpp = crate::route::on_cpp();
+ let edges = [[30.0, 40.0, 20.0], [60.0, 20.0, 20.0], [60.0, 40.0, 10.0]];
+ let (open, holes) = timed("setback_open", move || {
+ let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
+ blend_holes(&b, Blend::Round, &edges, &[5.0; 3], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).unwrap()
+ });
+ assert_eq!(counts(&open).unwrap(), [9, 30, 22]);
+ assert!(!super::is_manifold_solid(&open).unwrap());
+ let [hole] = holes.as_slice() else { panic!("one corner: {holes:?}") };
+ assert_eq!(hole.edges.len(), 12);
+ let flat = to_arrays(&open).unwrap();
+ let (rec, _) = native(&flat).unwrap();
+ rec.validate().unwrap();
+ let ends = &flat.ints[&crate::flat::EDGE_VERTICES];
+ let end = |h: &crate::HoleEdge, last: bool| ends[2 * h.edge + usize::from(h.forward == last)];
+ for (k, h) in hole.edges.iter().enumerate() {
+ let next = &hole.edges[(k + 1) % 12];
+ assert_eq!(end(h, true), end(next, false), "edge {k} ends where the next starts");
+ assert!(h.face < 9);
+ }
+ #[cfg(feature = "native")]
+ {
+ crate::init().unwrap();
+ let b = crate::ffi::make_box(&[0.0; 3], &[60.0, 40.0, 20.0]).map(crate::Brep::made).unwrap();
+ let (h, values) = crate::ffi::ops::blend_setback_open(
+ crate::ffi::ops::BLEND_ROUND,
+ b.cpp().unwrap(),
+ &edges,
+ &[5.0; 3],
+ &[[60.0, 40.0, 20.0]],
+ &[8.0],
+ 0.1,
+ )
+ .unwrap();
+ assert_eq!(crate::ffi::counts(&h).unwrap(), [9, 30, 22], "C++ counts");
+ let ours: Vec =
+ std::iter::once(12).chain(hole.edges.iter().flat_map(|h| [h.edge as i64, h.face as i64, i64::from(h.forward)])).collect();
+ assert_eq!(values, ours, "the hole (C++ vs solidcore)");
+ }
+ // A corner where an edge is not rounded builds no setback, and the refusal names it.
+ let refused = timed("setback_open_refused", move || {
+ let b = Shape::Box([0.0; 3], [60.0, 40.0, 20.0]).core();
+ blend_holes(&b, Blend::Round, &edges[..2], &[5.0; 2], 0.1, &[[60.0, 40.0, 20.0]], &[8.0], Corners::Open).err()
+ });
+ assert_eq!(refused, Some(Error::CornerRefused { corner: 0 }));
+}
+
+/// A curvature-continuous round at the equal-area thumbweight takes off what the ball's circle
+/// does, r² (cot(α/2) - (π - α)/2) per unit length on a wedge α (solidcore#2261): on a 40 x 30 x 20
+/// box with its top back edge chamfered 5, r 2 along the chamfer's top edge (α 135°) takes
+/// 0.0860579227 per mm, along the bottom front edge (α 90°) 0.8584073464. Both in one call are
+/// refused: every edge takes the first one's thumbweight, so the other would take 17% too little
+/// or 21% too much (solidcore#2250's comment 6563419897), whichever order they come in.
+#[test]
+fn a_curvature_round_takes_its_wedges_equal_area_and_mixed_wedges_are_refused() {
+ let r = 2.0;
+ let cut = |alpha: f64| r * r * (1.0 / (alpha / 2.0).tan() - (PI - alpha) / 2.0);
+ let chamfered =
+ blend(&Shape::Box([0.0; 3], [40.0, 30.0, 20.0]).core(), Blend::Chamfer, &[[20.0, 30.0, 20.0]], &[5.0], TOL, &[], &[], Corners::Filled)
+ .unwrap();
+ let v0 = volume(&chamfered, 1e-8).unwrap();
+ assert!(rel(v0, 23500.0) < 1e-9, "{v0}");
+ let (a, b) = ([20.0, 25.0, 20.0], [20.0, 0.0, 0.0]);
+ for (at, alpha) in [(a, 0.75 * PI), (b, 0.5 * PI)] {
+ let round = blend(&chamfered, Blend::Curvature, &[at], &[r], TOL, &[], &[], Corners::Filled).unwrap();
+ let (rec, _) = native(&to_arrays(&round).unwrap()).unwrap();
+ rec.validate().unwrap();
+ let removed = v0 - volume(&round, 1e-8).unwrap();
+ assert!(rel(removed, 40.0 * cut(alpha)) < 1e-6, "{removed} vs {}", 40.0 * cut(alpha));
+ }
+ for both in [[a, b], [b, a]] {
+ let refused = blend(&chamfered, Blend::Curvature, &both, &[r], TOL, &[], &[], Corners::Filled).err();
+ assert_eq!(refused, Some(Error::Unported(super::blend::CURVATURE_MIXED_WEDGES)));
+ assert!(refused.is_some_and(|e| e.to_string().contains("issuecomment-6563419897")));
+ }
+ // Edges of one wedge go together.
+ let two = blend(&chamfered, Blend::Curvature, &[b, [20.0, 0.0, 20.0]], &[r], TOL, &[], &[], Corners::Filled).unwrap();
+ assert!(rel(v0 - volume(&two, 1e-8).unwrap(), 80.0 * cut(0.5 * PI)) < 1e-6);
+ // A pick that misses is still named first.
+ assert_eq!(blend(&chamfered, Blend::Curvature, &[[90.0; 3]], &[r], TOL, &[], &[], Corners::Filled).err(), Some(Error::NoMatch { point: 0 }));
+}
+
+/// A variable round is the union of its balls (solidcore#2262, `fillet_gn_section_generator`).
+/// Along the 30 mm top front edge: a law of one radius 2 takes the constant round's
+/// (1 - π/4) 4 · 30 = 25.752220; 1 to 3, the balls' union's 27.857434 (the great circles would take
+/// 27.898239), either way along the edge. Both come out 5.3e-5 of that over, as on the C++
+/// kernel: the solver's surface fit. A law whose radius climbs faster than a rolling ball can
+/// follow (1 to 4 within a mm) folds its balls over one another: refused, not built.
+#[test]
+fn a_variable_round_is_the_union_of_its_balls() {
+ let b = BLOCK.core();
+ let removed = |radii: &[f64], start: [f64; 3]| {
+ let r = fillet_variable(&b, TOP_FRONT, TOL, start, 1, &[0.0, 0.0, 1.0, 1.0], radii).unwrap();
+ let (rec, _) = native(&to_arrays(&r).unwrap()).unwrap();
+ rec.validate().unwrap();
+ ABC - volume(&r, 1e-8).unwrap()
+ };
+ let constant = removed(&[2.0, 2.0], [0.0, 0.0, 10.0]);
+ assert!(rel(constant, (1.0 - PI / 4.0) * 4.0 * 30.0) < 1e-4, "{constant}");
+ let linear = removed(&[1.0, 3.0], [0.0, 0.0, 10.0]);
+ assert!(rel(linear, 27.857434) < 1e-4, "{linear}");
+ assert!(rel(removed(&[1.0, 3.0], [30.0, 0.0, 10.0]), linear) < 1e-8);
+ let steep = [0.0, 0.0, 0.0, 0.0, 0.02, 0.04, 1.0, 1.0, 1.0, 1.0];
+ let folds = fillet_variable(&b, TOP_FRONT, TOL, [0.0, 0.0, 10.0], 3, &steep, &[1.0, 1.0, 4.0, 4.0, 4.0, 4.0]).err();
+ assert_eq!(folds, Some(Error::NoRoom));
+ // A pick that misses is still named first.
+ assert_eq!(fillet_variable(&b, [90.0; 3], TOL, [0.0; 3], 1, &[0.0, 0.0, 1.0, 1.0], &[1.0, 3.0]).err(), Some(Error::NoMatch { point: 0 }));
+}
+
+/// The capi's argument checks, before any kernel call.
+#[test]
+fn hostile_arguments_are_refused() {
+ let b = BLOCK.core();
+ let none = Corners::Filled;
+ assert_eq!(blend(&b, Blend::Round, &[], &[1.0], TOL, &[], &[], none).err(), Some(Error::Argument));
+ assert_eq!(blend(&b, Blend::Round, &[[f64::NAN; 3]], &[1.0], TOL, &[], &[], none).err(), Some(Error::Argument));
+ assert_eq!(blend(&b, Blend::Round, &[TOP_FRONT], &[-1.0], TOL, &[], &[], none).err(), Some(Error::Argument));
+ assert_eq!(blend(&b, Blend::Round, &[TOP_FRONT], &[1.0], 0.0, &[], &[], none).err(), Some(Error::Argument));
+ assert_eq!(blend(&b, Blend::Round, &[TOP_FRONT], &[f64::INFINITY], TOL, &[], &[], none).err(), Some(Error::Argument));
+ // A chamfer's corners are bevelled, which builds no setback.
+ assert_eq!(blend(&b, Blend::Chamfer, &[TOP_FRONT], &[1.0], TOL, &[[0.0, 0.0, 10.0]], &[1.0], none).err(), Some(Error::Unsupported));
+ assert_eq!(fillet_variable(&b, TOP_FRONT, TOL, [0.0; 3], 1, &[0.0, 1.0], &[1.0, 2.0]).err(), Some(Error::Argument));
+ // A radius that pinches to nothing inside the edge.
+ assert_eq!(fillet_variable(&b, TOP_FRONT, TOL, [0.0; 3], 1, &[0.0, 0.0, 0.5, 1.0, 1.0], &[1.0, 0.0, 1.0]).err(), Some(Error::Unsupported));
+ assert_eq!(chamfer_sides(&b, &[TOP_FRONT], TOL, 1.0, ChamferSecond::Angle(4.0), [0.0, 0.0, 1.0]).err(), Some(Error::Argument));
+ assert_eq!(chamfer_sides(&b, &[TOP_FRONT], TOL, 1.0, ChamferSecond::Distance(1.0), [0.0; 3]).err(), Some(Error::Argument));
+}
+
+/// A setback longer than an edge at its corner leaves no room, before any blend is made.
+#[test]
+fn a_setback_past_an_edge_has_no_room() {
+ let r = timed("setback_long", || {
+ let b = BLOCK.core();
+ let corner = [[15.0, 20.0, 10.0], [30.0, 10.0, 10.0], [30.0, 20.0, 5.0]];
+ blend(&b, Blend::Round, &corner, &[2.0; 3], TOL, &[[30.0, 20.0, 10.0]], &[10.0], Corners::Filled).err()
+ });
+ assert_eq!(r, Some(Error::NoRoom));
+}
+
+/// The body blended is never changed.
+#[test]
+fn the_body_stays_as_it_was() {
+ let r = timed("operand", || {
+ let b = BLOCK.core();
+ let _ = blend(&b, Blend::Round, &[TOP_FRONT], &[2.0], TOL, &[], &[], Corners::Filled);
+ (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 chamfer's first distance goes on the face leaning most along `first`: 2 along the top and
+/// 3 down the front for +z, the other way round for -y. The volumes are the same; the centroid
+/// tells them apart. The removed prism's section is the triangle with legs t (back along the
+/// top, in y) and f (down the front, in z) at the edge (y = 0, z = 10), centroid
+/// (t/3, 10 - f/3), area t f / 2: the block keeps ȳ = (6000·10 - 15 t f · t/3) / (6000 - 15 t f)
+/// and z̄ = (6000·5 - 15 t f (10 - f/3)) / (6000 - 15 t f).
+#[test]
+fn the_first_distance_goes_on_the_face_along_first() {
+ for (first, top, front) in [([0.0, 0.0, 1.0], 2.0, 3.0), ([0.0, -1.0, 0.0], 3.0, 2.0)] {
+ let r = timed("sides_first", move || {
+ let b = BLOCK.core();
+ chamfer_sides(&b, &[TOP_FRONT], TOL, 2.0, ChamferSecond::Distance(3.0), first).and_then(|c| super::mass_properties(&c, 1e-8))
+ });
+ let m = r.unwrap_or_else(|e| panic!("{first:?}: {e}"));
+ let cut = 15.0 * top * front;
+ let y = (6000.0 * 10.0 - cut * top / 3.0) / (6000.0 - cut);
+ let z = (6000.0 * 5.0 - cut * (10.0 - front / 3.0)) / (6000.0 - cut);
+ assert!(rel(m.volume, 6000.0 - cut) < 1e-9, "{first:?}: volume {}", m.volume);
+ assert!((m.centroid[1] - y).abs() < 1e-7 && (m.centroid[2] - z).abs() < 1e-7, "{first:?}: centroid {:?} vs ({y}, {z})", m.centroid);
+ }
+}
+
+/// Face and edge tags (`Brep::tagged`) through blends 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 solidcraft_geom::brep::history::Change;
+
+ use crate::history::Raw;
+ use crate::{Blend, Brep, ffi};
+
+ /// A 20 x 30 x 10 box, tagged as operand 0, with `kind` of `size` on its top front edge
+ /// (y = 0, z = 10), on both kernels: solidcore's history, then the C++ kernel's.
+ fn one_edge(kind: Blend, size: f64) -> (Raw, Raw) {
+ crate::init().unwrap();
+ let a = Brep::made(ffi::make_box(&[0.0; 3], &[20.0, 30.0, 10.0]).unwrap()).tagged(0).unwrap();
+ let edge = [[10.0, 0.0, 10.0]];
+ let code = 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 cpp = ffi::ops::blend(code, a.cpp().unwrap(), &edge, &[size], super::TOL).unwrap();
+ let cpp = Brep::made(cpp).history().unwrap();
+ let core = super::blend(a.core().unwrap(), kind, &edge, &[size], super::TOL, &[], &[], super::Corners::Filled).unwrap();
+ (Brep::made_core(core).history().unwrap(), cpp)
+ }
+
+ /// The changes of `h`'s faces, sorted: one blend of one box edge leaves the bottom and back
+ /// as they were, trims the top, front and both sides, and makes the blend face.
+ fn changes(h: &Raw) -> Vec {
+ let mut c: Vec = h.faces.iter().map(|f| f.change).collect();
+ c.sort_by_key(|c| *c as u8);
+ c
+ }
+
+ fn check(kind: Blend, size: f64) {
+ let (core, cpp) = one_edge(kind, size);
+ let want = [Change::Kept, Change::Kept, Change::Modified, Change::Modified, Change::Modified, Change::Modified, Change::Generated];
+ assert_eq!(changes(&cpp), want, "C++ {kind:?}: {cpp:?}");
+ assert_eq!(core.canonical(), cpp.canonical(), "{kind:?}");
+ }
+
+ /// A 2 mm round on the top front edge of a box: the top and front are trimmed, the round is
+ /// new.
+ #[test]
+ fn tags_cross_a_fillet() {
+ let _cpp = crate::route::on_cpp();
+ check(Blend::Round, 2.0);
+ }
+
+ /// A 2 mm chamfer on the same edge: the same faces kept, trimmed and made as the round.
+ #[test]
+ fn tags_cross_a_chamfer() {
+ let _cpp = crate::route::on_cpp();
+ check(Blend::Chamfer, 2.0);
+ }
+}
+
+/// The capi's `knife_edge_chamfers` (fork e96898d): a 10 high extrusion of the profile whose arc
+/// of radius R = 21 runs out tangent into its top line at the tip x = 62 1/6 (a knife edge, a
+/// cusp), chamfered d at the tip. No ball lies d back along both faces there; the chamfer is the
+/// chord between the contacts of the ball of radius d, sqrt(4 R d) back on the flat face and where
+/// the line from the arc's axis to the ball's centre meets the wall. It takes the triangle flat
+/// contact, tip, wall contact less the wall's circular segment R²/2 (b - sin b),
+/// b = acos((R - d)/(R + d)): at d = 2 the chord is 3.822 wide and takes 159.23645 (each to
+/// 1e-12). Mirrored in x too.
+#[test]
+fn knife_edge_chamfers() {
+ use super::sweep::extrude;
+ use super::transform::transform;
+ use crate::sweeps::{Curve, DraftCorner};
+
+ const R: f64 = 21.0;
+ const TIP: f64 = 62.0 + 1.0 / 6.0;
+ const H: f64 = 10.0;
+ let line = |a: [f64; 2], b: [f64; 2]| Curve::Line { start: [a[0], a[1], 0.0], end: [b[0], b[1], 0.0] };
+ let profile = [
+ line([0.0, 0.0], [0.0, -R]),
+ line([0.0, -R], [TIP - R, -R]),
+ Curve::Arc { center: [TIP, -R, 0.0], axis: [0.0, 0.0, -1.0], reference: [1.0, 0.0, 0.0], radius: R, start: PI, end: 1.5 * PI },
+ line([TIP, 0.0], [0.0, 0.0]),
+ ];
+ let part = extrude(&profile, [0.0, 0.0, 1.0], H, 0.0, DraftCorner::Curvature, true).unwrap();
+ let before = (TIP - R) * R * H + R * R * (1.0 - PI / 4.0) * H;
+ assert!((volume(&part, 1e-8).unwrap() - before).abs() < 1e-9 * before);
+ for d in [1.0, 2.0, 5.0] {
+ let flat = TIP - (4.0 * R * d).sqrt();
+ let (wx, wy) = (TIP - (4.0 * R * d).sqrt() * R / (R + d), -R + (R - d) * R / (R + d));
+ let triangle = 0.5 * ((TIP - flat) * wy).abs();
+ let b = ((R - d) / (R + d)).acos();
+ let removed = (triangle - 0.5 * R * R * (b - b.sin())) * H;
+ if d == 2.0 {
+ assert!((removed - 159.236_450_742_901_18).abs() < 1e-12 * 159.24);
+ assert!(((wx - flat).hypot(wy) - 3.822_132_343_623_634_7).abs() < 1e-12 * 3.83);
+ }
+ for mirrored in [false, true] {
+ let sx = if mirrored { -1.0 } else { 1.0 };
+ let m = [[sx, 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]];
+ let body = transform(&part, &m).unwrap();
+ let tip = [sx * TIP, 0.0, H / 2.0];
+ let cut = blend(&body, Blend::Chamfer, &[tip], &[d], TOL, &[], &[], Corners::Filled).unwrap();
+ let v = volume(&cut, 1e-8).unwrap();
+ let want = before - removed;
+ assert!((v - want).abs() < 1e-9 * want, "d {d} mirrored {mirrored}: {v} vs {want}");
+ native(&to_arrays(&cut).unwrap()).unwrap();
+ }
+ }
+}
+
+/// The part from SolidCraft #871: a plate with a narrower block on top and a window through both.
+fn part_871() -> Body {
+ let (a, b) = (12.74437365538526, 22.69275963366936);
+ let at = |lo: [f64; 3], hi: [f64; 3]| make_box(lo, [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]).unwrap();
+ let joined = boolean(&at([9.5, 8.0, 0.0], [23.5, 29.5, 10.0]), &at([9.5, a, 10.0], [23.5, b, 20.0]), BooleanOp::Union).unwrap().unwrap();
+ boolean(&joined, &at([9.5, 14.0, 7.0], [23.5, 19.2, 14.1]), BooleanOp::Subtract).unwrap().unwrap()
+}
+
+/// The 12 edges round each of the part's two end faces.
+fn edges_871() -> Vec<[f64; 3]> {
+ let yz = [
+ (12.74437365538526, 15.0),
+ (10.37218682769263, 10.0),
+ (8.0, 5.0),
+ (18.75, 0.0),
+ (29.5, 5.0),
+ (26.09637981683468, 10.0),
+ (22.69275963366936, 15.0),
+ (17.71856664452731, 20.0),
+ (16.6, 7.0),
+ (14.0, 10.55),
+ (16.6, 14.100000000000001),
+ (19.200000000000003, 10.55),
+ ];
+ [23.5, 9.5].iter().flat_map(|x| yz.iter().map(move |(y, z)| [*x, *y, *z])).collect()
+}
+
+/// #871's rounds at r 0.5 fit (the wall between the window and the block's side is 1.26 thick):
+/// 38 faces, and the closed form `issue_871_tests::rounds_that_fit_are_closed` derives. At r 1 they
+/// overlap, which is refused. With SMLib built, the C++ kernel gives the same.
+#[cfg(feature = "native")]
+#[test]
+fn rounds_on_the_871_part() {
+ let _cpp = crate::route::on_cpp();
+ rounds_on_the_871_part_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 (rounds_on_the_871_part) is graded only where it runs.
+#[cfg(not(feature = "native"))]
+#[test]
+fn rounds_on_the_871_part_on_solidcore() {
+ rounds_on_the_871_part_check();
+}
+
+fn rounds_on_the_871_part_check() {
+ let p = part_871();
+ let e = edges_871();
+ let sizes = |r: f64| vec![r; e.len()];
+ let part_volume = 14.0 * (21.5 * 10.0 + (22.69275963366936 - 12.74437365538526) * 10.0 - 5.2 * 7.1);
+ let exact = part_volume - 2.0 * (2.0 * (21.5 + 20.0) + 2.0 * (5.2 + 7.1)) * spandrel(0.5);
+ let fit = blend(&p, Blend::Round, &e, &sizes(0.5), TOL, &[], &[], Corners::Filled).unwrap();
+ let (v, n) = (volume(&fit, 1e-8).unwrap(), counts(&fit).unwrap());
+ let refused = blend(&p, Blend::Round, &e, &sizes(1.0), TOL, &[], &[], Corners::Filled).map(|_| ()).unwrap_err();
+ assert_eq!(refused, Error::NoRoom);
+ assert_eq!(n[0], 38);
+ assert!((v - exact).abs() < 1e-3, "{v} vs {exact}");
+ #[cfg(feature = "native")]
+ {
+ use crate::{Brep, ffi};
+ crate::init().unwrap();
+ let bx = |lo: [f64; 3], hi: [f64; 3]| ffi::make_box(&lo, &[hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]]).unwrap();
+ let top = ffi::ops::boolean(
+ &bx([9.5, 8.0, 0.0], [23.5, 29.5, 10.0]),
+ &bx([9.5, 12.74437365538526, 10.0], [23.5, 22.69275963366936, 20.0]),
+ ffi::ops::BOOLEAN_UNION,
+ )
+ .unwrap();
+ let cut = ffi::ops::boolean(&top, &bx([9.5, 14.0, 7.0], [23.5, 19.2, 14.1]), ffi::ops::BOOLEAN_SUBTRACT).unwrap();
+ let cpp = Brep::made(cut);
+ let h = cpp.cpp().unwrap();
+ let fit = ffi::ops::blend(ffi::ops::BLEND_ROUND, h, &e, &sizes(0.5), TOL).unwrap();
+ let (cv, cn) = (ffi::volume(&fit, 1e-8).unwrap(), ffi::counts(&fit).unwrap().map(|x| usize::try_from(x).unwrap()));
+ let crefused = ffi::ops::blend(ffi::ops::BLEND_ROUND, h, &e, &sizes(1.0), TOL).map(|_| ());
+ assert!(rel(v, cv) < 1e-9, "solidcore {v} vs C++ {cv}");
+ assert_eq!(n, cn);
+ assert!(crefused.is_err());
+ }
+}
+
+/// The fork's `tabbed_plate_rounds_mitred_on_the_arcs_continuation`: a plate 2 thick, its outline an
+/// R25 arc to the sides x = +-4 of a tab whose top is 4 wide with R2 corners, every top edge of the
+/// outline rounded r 0.5. The arc's round and the tab side's are mitred on the arc wall's
+/// continuation. Removed: the outline's area less the area of the outline moved in by
+/// d = r (1 - cos phi), at the height r sin phi into the round (midpoint rule, 8192 steps; the area
+/// by Green's theorem). With SMLib built, the C++ kernel gives the same.
+#[test]
+fn tabbed_plate_rounds_mitred_on_the_arcs_continuation() {
+ #[cfg(feature = "native")]
+ let _cpp = crate::route::on_cpp();
+ use super::sweep::extrude;
+ use crate::sweeps::{Curve, DraftCorner};
+ const Z: [f64; 3] = [0.0, 0.0, 1.0];
+ let theta = (4.0_f64 / 25.0).acos();
+ let y = 609.0_f64.sqrt();
+ let cy = 280.0 / 9.0;
+ let line = |a: [f64; 2], b: [f64; 2]| Curve::Line { start: [a[0], a[1], 0.0], end: [b[0], b[1], 0.0] };
+ let arc = |c: [f64; 2], radius, start, end| Curve::Arc { center: [c[0], c[1], 0.0], axis: Z, reference: [1.0, 0.0, 0.0], radius, start, end };
+ let outline = [
+ arc([0.0, 0.0], 25.0, PI - theta, 2.0 * PI + theta),
+ line([4.0, y], [4.0, cy]),
+ arc([2.0, cy], 2.0, 0.0, PI / 2.0),
+ line([2.0, cy + 2.0], [-2.0, cy + 2.0]),
+ arc([-2.0, cy], 2.0, PI / 2.0, PI),
+ line([-4.0, cy], [-4.0, y]),
+ ];
+ let area = |da: f64, dt: f64| {
+ let (big, rho) = (25.0 - da, 2.0 - dt);
+ let tab = rho * rho * PI + 2.0 * rho * (2.0 + cy) + 4.0 * (cy + rho);
+ let yc = (big * big - (4.0 - dt) * (4.0 - dt)).sqrt();
+ let at = yc.atan2(4.0 - dt);
+ 0.5 * (big * big * (PI + 2.0 * at) + tab) + (4.0 - dt) * (cy - yc)
+ };
+ let (r, dphi) = (0.5, (PI / 2.0) / 8192.0);
+ let want: f64 = (0..8192)
+ .map(|i| {
+ let phi = (f64::from(i) + 0.5) * dphi;
+ let d = r * (1.0 - phi.cos());
+ (area(0.0, 0.0) - area(d, d)) * r * phi.cos() * dphi
+ })
+ .sum();
+ let top = [
+ [0.0, -25.0, 2.0],
+ [4.0, 27.8945, 2.0],
+ [3.41421356, 32.5253247, 2.0],
+ [0.0, cy + 2.0, 2.0],
+ [-3.41421356, 32.5253247, 2.0],
+ [-4.0, 27.8945, 2.0],
+ ];
+ let plate = extrude(&outline, Z, 2.0, 0.0, DraftCorner::default(), true).unwrap();
+ assert!(rel(volume(&plate, 1e-8).unwrap(), 2.0 * area(0.0, 0.0)) < 1e-10);
+ let round = blend(&plate, Blend::Round, &top, &[r; 6], TOL, &[], &[], Corners::Filled).unwrap();
+ let removed = volume(&plate, 1e-8).unwrap() - volume(&round, 1e-8).unwrap();
+ assert!(rel(removed, want) < 1e-6, "removed {removed}, expected {want}");
+ let (rec, _) = native(&to_arrays(&round).unwrap()).unwrap();
+ rec.validate().unwrap();
+ assert!(super::is_manifold_solid(&round).unwrap());
+ assert!(super::check(&round, crate::check::CheckLevel::All).unwrap().is_valid());
+ #[cfg(feature = "native")]
+ {
+ use crate::{Brep, ffi};
+ crate::init().unwrap();
+ let list = crate::sweeps::List::of(&outline).unwrap();
+ let cpp = ffi::sweeps::extrude(&list.raw(), &Z, 2.0, 0.0, ffi::sweeps::DRAFT_CORNER_CURVATURE, true, &mut [f64::NAN; 3]).unwrap();
+ let cround = ffi::ops::blend(ffi::ops::BLEND_ROUND, Brep::made(cpp).cpp().unwrap(), &top, &[r; 6], TOL).unwrap();
+ let cv = ffi::volume(&cround, 1e-8).unwrap();
+ assert!(rel(volume(&round, 1e-8).unwrap(), cv) < 1e-9);
+ assert_eq!(counts(&round).unwrap(), ffi::counts(&cround).unwrap().map(|x| usize::try_from(x).unwrap()));
+ }
+}
diff --git a/crates/smlib/src/core/boolean.rs b/crates/smlib/src/core/boolean.rs
new file mode 100644
index 0000000000..2fac368df1
--- /dev/null
+++ b/crates/smlib/src/core/boolean.rs
@@ -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),
+ /// `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 {
+ 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 {
+ 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, uses: SolidResult) -> 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 {
+ 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> {
+ // 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>) -> Result > {
+ 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 {
+ 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> {
+ 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 {
+ 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 > {
+ 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 > {
+ 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)),
+ }
+}
diff --git a/crates/smlib/src/core/boolean_tests.rs b/crates/smlib/src/core/boolean_tests.rs
new file mode 100644
index 0000000000..8b6606a158
--- /dev/null
+++ b/crates/smlib/src/core/boolean_tests.rs
@@ -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),
+ 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,
+ faces: Option,
+ solids: usize,
+}
+
+const A: Shape = Shape::Box([0.0; 3], [10.0; 3]);
+
+fn case(name: &str) -> Case {
+ let plain = |op, volume: Option, 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,
+ solids: usize,
+}
+
+fn kinds(rec: &solidcraft_geom::brep::Brep) -> BTreeMap {
+ 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> {
+ match &c.op {
+ Op::Boolean(a, b, op) => boolean(&a.core(), &b.core(), *op),
+ Op::UnionAll(shapes) => {
+ let bodies: Vec = shapes.iter().map(|s| s.core()).collect();
+ union_all(&bodies.iter().collect::>()).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(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 {
+ 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 {
+ 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 = 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 = (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 = (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}");
+ }
+}
diff --git a/crates/smlib/src/core/convert.rs b/crates/smlib/src/core/convert.rs
new file mode 100644
index 0000000000..56988f618b
--- /dev/null
+++ b/crates/smlib/src/core/convert.rs
@@ -0,0 +1,2604 @@
+//! solidcore's B-rep to and from the C API's flat arrays ([`crate::flat`]): ports of
+//! `capi/src/smlib_flat.cpp`, so a body converts exactly as it does through the C++ kernel.
+//! - [`to_arrays`]: `FlatFromBrep` (SmBrep, through `SmBrepData::FromBrep`, to arrays): the same
+//! ids, the same face, edge and vertex order (`GetFaces`, `GetEdges`), analytic kinds with
+//! ranges in radians, NURBS as splines, SMLib's own UV trims and tolerances.
+//! - [`from_arrays`]: `BrepFromFlat` (`Validate`, `FlatToBrepData`, then
+//! `SmBrep::MakeTopologyFromData`, healing unless the arrays say SMLib wrote them).
+//!
+//! Tolerances differ from the C++ on purpose: `to_arrays` writes the B-rep's own tolerance as
+//! TOLERANCE, not the largest of its entities', and `from_arrays` takes the B-rep's and the faces'
+//! from the edges' and vertices' own ([`own_tolerances`]). Each entity keeps its tolerance across
+//! the kernels instead of every one taking the largest at each crossing.
+//!
+//! A face whose surface is not a B-spline kind (an offset) exports its approximation, as
+//! `FlatFromBrep` does (`SmSurface::ApproximateSurface`).
+//!
+//! [`from_arrays`] also runs the capi's `PutEnclosingShellsFirst` ([`put_enclosing_shells_first`],
+//! an addition to SmuConvert): a bounded region of several shells starts its list at the shell
+//! `SmShell::IsInnerShellOf` shows enclosing the others, whatever order the arrays gave.
+
+// The SmBrepData records are filled field by field, as FlatToBrepData fills them after ReSet.
+#![allow(clippy::field_reassign_with_default)]
+
+use std::borrow::Cow;
+use std::collections::HashMap;
+
+use solidcore::curve::curve::p1::ApproximateCurveOptions;
+use solidcore::curve::curve::{Curve, CurveKind};
+use solidcore::curve::ellipse::{Ellipse, EllipseOptions};
+use solidcore::curve::line::Line;
+use solidcore::geom::attribute::{AttributeStore, ObjectKey};
+use solidcore::geom::axis2_placement::Axis2Placement;
+use solidcore::geom::extent1d::Extent1d;
+use solidcore::geom::extent2d::Extent2d;
+use solidcore::geom::object::ObjectRef;
+use solidcore::geom::tol::approx_tol3d;
+use solidcore::nurbs::b_spline_curve::{BSplineCurve, BSplineCurveForm, CanonicalData, KnotType};
+use solidcore::nurbs::b_spline_surface::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData};
+use solidcore::nurbs::crv_basic::Curve as NurbCurve;
+use solidcore::nurbs::math::{CPoint, NOW, NOZ};
+use solidcore::nurbs::srf_basic::Surface as NurbSurface;
+use solidcore::query::brep::{BoundingBoxOptions, calculate_bounding_box};
+use solidcore::surface::cone::Cone;
+use solidcore::surface::plane::Plane;
+use solidcore::surface::sphere::Sphere;
+use solidcore::surface::surface::{Surface, SurfaceKind};
+use solidcore::surface::torus::{Torus, TorusOptions};
+use solidcore::topology::brep::p1 as bl;
+use solidcore::topology::brep::p10::{HEALER_VERSION, MakeTopologyOptions, TopologyEnv, make_topology_from_data};
+use solidcore::topology::brep_data::{
+ BrepData, DataSourceType, EdgeData, EuData, FaceData, FaceuseData, Keyed, LoopData, NO_OBJECT, RegionData, ShellData, UNDEF_ULONG, UNSURE,
+ VertexData,
+};
+use solidcore::topology::brep_data_write::FromBrepEnv;
+use solidcore::topology::edgeuse::{p1 as eu1, p3 as eu3};
+use solidcore::topology::topology::NotifyRecords;
+use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId, ShellId, VertexId, edge, shell, vertex};
+use solidcore::{EntityRef, Kernel, Point2, Point3, SolidError, Vec3};
+
+use crate::flat::*;
+use crate::import::Arrays;
+use crate::{Error, Result};
+
+use super::{Body, answer, run, with_body};
+
+// ---- shared ------------------------------------------------------------------------------------
+
+/// A solidcore error from `call`.
+fn sc(call: &'static str) -> impl Fn(SolidError) -> Error {
+ move |e| Error::Solidcore(format!("{call}: {e}"))
+}
+
+/// The arrays (or the body) are not what the conversion can carry: SMLib's `SM_ERR`.
+fn bad(what: &str) -> Error {
+ Error::Solidcore(format!("flat arrays: {what}"))
+}
+
+fn get(v: &[T], i: usize) -> Result {
+ v.get(i).copied().ok_or_else(|| bad("index past its array"))
+}
+
+fn get3(v: &[f64], i: usize) -> Result {
+ let at = |k: usize| i.checked_mul(3).and_then(|j| j.checked_add(k)).map_or_else(|| Err(bad("index past its array")), |j| get(v, j));
+ Ok(Vec3::new(at(0)?, at(1)?, at(2)?))
+}
+
+fn idx(x: i64) -> Result {
+ usize::try_from(x).map_err(|_| bad("negative index"))
+}
+
+fn count(n: usize) -> Result {
+ i64::try_from(n).map_err(|_| bad("count"))
+}
+
+fn index_u64(x: u64) -> Result {
+ usize::try_from(x).map_err(|_| bad("index"))
+}
+
+/// `SM_DEG2RAD`.
+fn deg2rad(d: f64) -> f64 {
+ d * (std::f64::consts::PI / 180.0)
+}
+
+/// `SM_RAD2DEG`.
+fn rad2deg(r: f64) -> f64 {
+ r * (180.0 / std::f64::consts::PI)
+}
+
+/// `SnapSMLibAngularBoundaryDeg`.
+fn snap_angular_boundary_deg(degrees: f64) -> f64 {
+ for quarter in -4..=4 {
+ let boundary = 90.0 * f64::from(quarter);
+ if (degrees - boundary).abs() <= 1.0e-9 {
+ return boundary;
+ }
+ }
+ degrees
+}
+
+/// `UsdRadiansToSMLibDegrees`.
+fn radians_to_smlib_degrees(radians: f64) -> f64 {
+ snap_angular_boundary_deg(rad2deg(radians))
+}
+
+/// `SM_INFINITE_PARAMETER`.
+const INFINITE_PARAMETER: f64 = 1_234_567.0;
+/// `SM_EFF_ZERO`.
+const EFF_ZERO: f64 = 1.0e-12;
+/// `SM_IS_INFINITE`.
+fn is_infinite(a: f64) -> bool {
+ a.abs() == INFINITE_PARAMETER || a.abs() == 1111.111
+}
+
+/// The flat arrays' writer: every array id, empty until written.
+struct Out(Arrays);
+
+impl Out {
+ fn new() -> Out {
+ let mut a = Arrays::default();
+ for id in 1..=WIRE_EDGE_NURBS_FORM {
+ a.ints.insert(id, Vec::new());
+ }
+ for id in TOLERANCE..=VERTEX_TOLERANCE {
+ a.reals.insert(id, Vec::new());
+ }
+ Out(a)
+ }
+ fn i(&mut self, id: i32) -> &mut Vec {
+ self.0.ints.entry(id).or_default()
+ }
+ fn r(&mut self, id: i32) -> &mut Vec {
+ self.0.reals.entry(id).or_default()
+ }
+ fn r3(&mut self, id: i32, p: Vec3) {
+ self.r(id).extend([p.x, p.y, p.z]);
+ }
+}
+
+/// The arrays of one edge family (edges or wire edges), by id (`EdgeArraysOut`).
+struct EdgeIds {
+ ty: i32,
+ range: i32,
+ count: i32,
+ order: i32,
+ cvs: i32,
+ knots: i32,
+ weights: i32,
+ circle_center: i32,
+ circle_axis: i32,
+ circle_ref: i32,
+ circle_radius: i32,
+ line_origin: i32,
+ line_direction: i32,
+ ellipse_center: i32,
+ ellipse_axis: i32,
+ ellipse_ref: i32,
+ ellipse_x: i32,
+ ellipse_y: i32,
+ form: i32,
+ vertices: i32,
+}
+
+const EDGES: EdgeIds = EdgeIds {
+ ty: EDGE_CURVE_TYPE,
+ range: EDGE_RANGE,
+ count: EDGE_NURBS_VERTEX_COUNT,
+ order: EDGE_NURBS_ORDER,
+ cvs: EDGE_NURBS_CVS,
+ knots: EDGE_NURBS_KNOTS,
+ weights: EDGE_NURBS_WEIGHTS,
+ circle_center: EDGE_CIRCLE_CENTER,
+ circle_axis: EDGE_CIRCLE_AXIS,
+ circle_ref: EDGE_CIRCLE_REF_DIRECTION,
+ circle_radius: EDGE_CIRCLE_RADIUS,
+ line_origin: EDGE_LINE_ORIGIN,
+ line_direction: EDGE_LINE_DIRECTION,
+ ellipse_center: EDGE_ELLIPSE_CENTER,
+ ellipse_axis: EDGE_ELLIPSE_AXIS,
+ ellipse_ref: EDGE_ELLIPSE_REF_DIRECTION,
+ ellipse_x: EDGE_ELLIPSE_X_RADIUS,
+ ellipse_y: EDGE_ELLIPSE_Y_RADIUS,
+ form: EDGE_NURBS_FORM,
+ vertices: EDGE_VERTICES,
+};
+
+const WIRE_EDGES: EdgeIds = EdgeIds {
+ ty: WIRE_EDGE_CURVE_TYPE,
+ range: WIRE_EDGE_RANGE,
+ count: WIRE_EDGE_NURBS_VERTEX_COUNT,
+ order: WIRE_EDGE_NURBS_ORDER,
+ cvs: WIRE_EDGE_NURBS_CVS,
+ knots: WIRE_EDGE_NURBS_KNOTS,
+ weights: WIRE_EDGE_NURBS_WEIGHTS,
+ circle_center: WIRE_EDGE_CIRCLE_CENTER,
+ circle_axis: WIRE_EDGE_CIRCLE_AXIS,
+ circle_ref: WIRE_EDGE_CIRCLE_REF_DIRECTION,
+ circle_radius: WIRE_EDGE_CIRCLE_RADIUS,
+ line_origin: WIRE_EDGE_LINE_ORIGIN,
+ line_direction: WIRE_EDGE_LINE_DIRECTION,
+ ellipse_center: WIRE_EDGE_ELLIPSE_CENTER,
+ ellipse_axis: WIRE_EDGE_ELLIPSE_AXIS,
+ ellipse_ref: WIRE_EDGE_ELLIPSE_REF_DIRECTION,
+ ellipse_x: WIRE_EDGE_ELLIPSE_X_RADIUS,
+ ellipse_y: WIRE_EDGE_ELLIPSE_Y_RADIUS,
+ form: WIRE_EDGE_NURBS_FORM,
+ vertices: WIRE_EDGE_VERTICES,
+};
+
+const CURVE_NURBS_I: i64 = CURVE_NURBS as i64;
+const CURVE_LINE_I: i64 = CURVE_LINE as i64;
+const CURVE_CIRCLE_I: i64 = CURVE_CIRCLE as i64;
+const CURVE_ELLIPSE_I: i64 = CURVE_ELLIPSE as i64;
+const SURFACE_NURBS_I: i64 = SURFACE_NURBS as i64;
+const SURFACE_PLANE_I: i64 = SURFACE_PLANE as i64;
+const SURFACE_CYLINDER_I: i64 = SURFACE_CYLINDER as i64;
+const SURFACE_CONE_I: i64 = SURFACE_CONE as i64;
+const SURFACE_SPHERE_I: i64 = SURFACE_SPHERE as i64;
+const SURFACE_TORUS_I: i64 = SURFACE_TORUS as i64;
+
+/// The `SmBSplineCurve` part of a curve that is one (`IsKindOf(SmBSplineCurve_TYPE)`), with its
+/// NURB (`GetOrCreateGwNurbPointer`: a lazy line or conic makes it on a copy); `None` for the
+/// other kinds.
+pub(super) fn bspline_curve_part<'c>(k: &Kernel, c: &'c Curve) -> Result>> {
+ Ok(Some(match c.kind() {
+ CurveKind::BSpline(b) => Cow::Borrowed(b),
+ CurveKind::Line(l) => l.base_with_nurb().map_err(sc("SmLine::GetOrCreateGwNurbPointer"))?,
+ CurveKind::Ellipse(e) if e.base().gw_nurb().is_none() => {
+ let mut made = e.clone();
+ made.make_nurb(k).map_err(sc("SmEllipse::MakeNurb"))?;
+ Cow::Owned(made.base().clone_exact())
+ }
+ CurveKind::Ellipse(e) => Cow::Borrowed(e.base()),
+ CurveKind::Hyperbola(h) if h.base().gw_nurb().is_none() => {
+ let mut made = h.clone();
+ made.make_nurb(None).map_err(sc("SmHyperbola::MakeNurb"))?;
+ Cow::Owned(made.base().clone_exact())
+ }
+ CurveKind::Hyperbola(h) => Cow::Borrowed(h.base()),
+ CurveKind::Parabola(p) if p.base().gw_nurb().is_none() => {
+ let mut made = p.clone();
+ made.make_nurb(None).map_err(sc("SmParabola::MakeNurb"))?;
+ Cow::Owned(made.base().clone_exact())
+ }
+ CurveKind::Parabola(p) => Cow::Borrowed(p.base()),
+ _ => return Ok(None),
+ }))
+}
+
+/// The `SmBSplineSurface` part of a surface that is one (`IsKindOf(SmBSplineSurface_TYPE)`), its
+/// NURB made first (`GetOrCreateGwNurbPointer`); `None` for the other kinds.
+fn bspline_surface_part<'s>(k: &Kernel, s: &'s Surface) -> Result > {
+ Ok(match s.kind_with_nurb(k).map_err(sc("SmSurface::GetOrCreateGwNurbPointer"))? {
+ SurfaceKind::BSpline(b) => Some(b),
+ SurfaceKind::Plane(p) => Some(p.base()),
+ SurfaceKind::Cone(c) => Some(c.rev().base()),
+ SurfaceKind::Sphere(s) => Some(s.base().base()),
+ SurfaceKind::Torus(t) => Some(t.base().base()),
+ SurfaceKind::SurfOfRevolution(r) => Some(r.base()),
+ SurfaceKind::SurfOfExtrusion(e) => Some(e.base()),
+ _ => None,
+ })
+}
+
+// ---- export: FlatFromBrep ----------------------------------------------------------------------
+
+/// `body` as the flat arrays the C API exports (`FlatFromBrep`).
+pub(crate) fn to_arrays(body: &Body) -> Result {
+ with_body("FlatFromBrep", body, |k, b| Ok(flat_from_brep(k, b)))?
+}
+
+/// One NURBS curve's arrays (`smu_AddSmBSplineCurve3d`, `smu_AddSmBSplineCurve2d`): counts and
+/// orders, cartesian control points of `dim` coordinates, knots and weights.
+struct NurbsOut<'a> {
+ count: &'a mut Vec,
+ order: &'a mut Vec,
+ cvs: &'a mut Vec,
+ knots: &'a mut Vec,
+ weights: &'a mut Vec,
+}
+
+/// `smu_AddSmBSplineCurve3d`: the curve's NURB, its control points made cartesian.
+fn add_bspline_curve_3d(nurb: &NurbCurve, o: NurbsOut<'_>) -> Result<()> {
+ o.count.push(count(nurb.cpts().len())?);
+ o.order.push(count(nurb.degree())?.saturating_add(1));
+ o.knots.extend_from_slice(nurb.knots());
+ for p in nurb.cpts() {
+ let (mut x, mut y, mut z, mut w) = (p.x, p.y, p.z, 1.0);
+ if p.w != NOW {
+ x /= p.w;
+ y /= p.w;
+ z /= p.w;
+ w = p.w;
+ }
+ o.cvs.extend([x, y, z]);
+ o.weights.push(w);
+ }
+ Ok(())
+}
+
+/// The NURB of a B-spline part (`GetOrCreateGwNurbPointer`, NULL being `SM_ERR`).
+fn nurb_of(part: &BSplineCurve) -> Result<&NurbCurve> {
+ part.gw_nurb().ok_or_else(|| bad("a B-spline curve without its NURB"))
+}
+
+/// `smu_AddSmBSplineCurve2d`: an edgeuse's UV trim, reversed when the edgeuse runs against its
+/// edge; `None` appends the empty curve. `remap` maps the knots from the edge's interval
+/// `[remap[0], remap[1]]` onto `[remap[2], remap[3]]`, `affine` the face's NURBS (u, v) onto the
+/// arrays' plane (u, v).
+fn add_bspline_curve_2d(o: &mut Out, trim: Option<&NurbCurve>, same: bool, remap: Option<[f64; 4]>, affine: Option<[f64; 6]>) -> Result<()> {
+ let Some(nurb) = trim else {
+ o.i(EDGEUSE_NURBS_VERTEX_COUNT).push(0);
+ o.i(EDGEUSE_NURBS_ORDER).push(0);
+ return Ok(());
+ };
+ let reversed;
+ let nurb = if same {
+ nurb
+ } else {
+ reversed = nurb.reversed().map_err(sc("N_CrvReverse"))?;
+ &reversed
+ };
+ o.i(EDGEUSE_NURBS_VERTEX_COUNT).push(count(nurb.cpts().len())?);
+ o.i(EDGEUSE_NURBS_ORDER).push(count(nurb.degree())?.saturating_add(1));
+ for &k in nurb.knots() {
+ let k = match remap {
+ Some(r) if k == r[1] => r[3],
+ Some(r) => r[2] + (k - r[0]) / (r[1] - r[0]) * (r[3] - r[2]),
+ None => k,
+ };
+ o.r(EDGEUSE_NURBS_KNOTS).push(k);
+ }
+ for p in nurb.cpts() {
+ let (mut x, mut y, mut w) = (p.x, p.y, 1.0);
+ if p.w != NOW {
+ x /= p.w;
+ y /= p.w;
+ w = p.w;
+ }
+ if let Some(a) = affine {
+ let ax = a[0] + a[1] * x + a[2] * y;
+ y = a[3] + a[4] * x + a[5] * y;
+ x = ax;
+ }
+ o.r(EDGEUSE_NURBS_CVS).extend([x, y]);
+ o.r(EDGEUSE_NURBS_WEIGHTS).push(w);
+ }
+ Ok(())
+}
+
+/// `smu_AddSmBSplineSurface`: a surface's NURB, control points U-major and cartesian. A knot
+/// vector without extent is `SM_ERR`.
+fn add_bspline_surface(o: &mut Out, nurb: &NurbSurface) -> Result<()> {
+ let (u, v) = (nurb.u_knots(), nurb.v_knots());
+ let spans = |k: &[f64]| matches!((k.first(), k.last()), (Some(a), Some(b)) if a < b);
+ if !spans(u) || !spans(v) {
+ return Err(bad("NURBS surface has degenerate knots and can't be converted"));
+ }
+ let (nu, nv) = (nurb.n().saturating_add(1), nurb.m().saturating_add(1));
+ o.i(FACE_NURBS_U_VERTEX_COUNT).push(count(nu)?);
+ o.i(FACE_NURBS_V_VERTEX_COUNT).push(count(nv)?);
+ o.i(FACE_NURBS_U_ORDER).push(count(nurb.p())?.saturating_add(1));
+ o.i(FACE_NURBS_V_ORDER).push(count(nurb.q())?.saturating_add(1));
+ o.r(FACE_NURBS_U_KNOTS).extend_from_slice(u);
+ o.r(FACE_NURBS_V_KNOTS).extend_from_slice(v);
+ for row in nurb.rows() {
+ if row.len() != nv {
+ return Err(bad("NURBS surface net"));
+ }
+ for p in row {
+ let (mut x, mut y, mut z, mut w) = (p.x, p.y, p.z, 1.0);
+ if p.w != NOW {
+ w = p.w;
+ x /= w;
+ y /= w;
+ z /= w;
+ }
+ o.r3(FACE_NURBS_CVS, Vec3::new(x, y, z));
+ o.r(FACE_NURBS_WEIGHTS).push(w);
+ }
+ }
+ Ok(())
+}
+
+/// `smu_CompactLocalIndex`: a BrepData index less the sorted removed indices below it.
+fn compact_index(i: u64, removed: &[u64]) -> Result {
+ let below = removed.partition_point(|&r| r < i);
+ count(index_u64(i)?.saturating_sub(below))
+}
+
+/// `EdgeCurveAsBSpline`: an edge curve's B-spline part, else its approximation within
+/// `approx_tol` over the edge interval (`ApproximateCurve`); `None` when it has neither.
+fn edge_curve_as_bspline<'c>(k: &Kernel, c: &'c Curve, ivl: &Extent1d, approx_tol: f64) -> Result>> {
+ if let Some(part) = bspline_curve_part(k, c)? {
+ return Ok(Some(part));
+ }
+ let Ok(approx) = c.approximate_curve(k, &[ivl.min(), ivl.max()], approx_tol, &ApproximateCurveOptions::default()) else {
+ return Ok(None);
+ };
+ Ok(bspline_curve_part(k, &approx.curve)?.map(|p| Cow::Owned(p.clone_exact())))
+}
+
+/// `BuildAnalyticEdgeCurve`: the curve the import builds for a line (unbounded,
+/// `center + s * axis`), circle or ellipse (over `[d_min, d_max]` degrees, its NURBS parameter
+/// then set to that interval with its notification, `EditParameterization`).
+fn build_analytic_edge_curve(k: &Kernel, ty: i64, d_min: f64, d_max: f64, center: Point3, axis: Vec3, refd: Vec3, r1: f64, r2: f64) -> Result {
+ analytic_edge_curve(k, ty, d_min, d_max, center, axis, refd, r1, r2).map_err(|(call, e)| sc(call)(e))
+}
+
+/// [`build_analytic_edge_curve`] with solidcore's own error, and the call that gave it.
+#[allow(clippy::too_many_arguments)] // BuildAnalyticEdgeCurve's
+fn analytic_edge_curve(
+ k: &Kernel,
+ ty: i64,
+ d_min: f64,
+ d_max: f64,
+ center: Point3,
+ axis: Vec3,
+ refd: Vec3,
+ r1: f64,
+ r2: f64,
+) -> std::result::Result {
+ if ty == CURVE_LINE_I {
+ return Ok(Curve::from(Line::create_canonical(center, axis)));
+ }
+ let mut placement = Axis2Placement::default();
+ placement.set_canonical(center, refd, axis.cross(refd)).map_err(|e| ("SmAxis2Placement::SetCanonical", e))?;
+ let degrees = Extent1d::from_min_max(d_min, d_max);
+ let mut e = if ty == CURVE_CIRCLE_I {
+ Ellipse::create_canonical_circle(k, &placement, r1, Some(°rees), None).map_err(|e| ("SmCircle::CreateCanonical", e))?
+ } else {
+ Ellipse::create_canonical(k, &placement, r1, r2, Some(°rees), None).map_err(|e| ("SmEllipse::CreateCanonical", e))?
+ };
+ e.edit_parameterization(k, °rees, true, None).map_err(|e| ("SmEllipse::EditParameterization", e))?;
+ Ok(Curve::from(e))
+}
+
+/// How one edge's curve is exported (`EdgeExport`).
+#[derive(Clone, Copy)]
+struct EdgeExport {
+ ty: i64,
+ /// The exported range: radians for circles and ellipses.
+ lo: f64,
+ hi: f64,
+ /// The imported edge's interval, in SMLib units (degrees for circles and ellipses).
+ sm_lo: f64,
+ sm_hi: f64,
+ center: Point3,
+ axis: Vec3,
+ refd: Vec3,
+ r1: f64,
+ r2: f64,
+ /// The imported curve's parameter is an affine image of the edge's.
+ affine: bool,
+}
+
+const NURBS_EXPORT: EdgeExport = EdgeExport {
+ ty: CURVE_NURBS_I,
+ lo: 0.0,
+ hi: 0.0,
+ sm_lo: 0.0,
+ sm_hi: 0.0,
+ center: Vec3::new(0.0, 0.0, 0.0),
+ axis: Vec3::new(0.0, 0.0, 0.0),
+ refd: Vec3::new(0.0, 0.0, 0.0),
+ r1: 0.0,
+ r2: 0.0,
+ affine: false,
+};
+
+/// `ClassifyEdgeCurve`: a circle, ellipse or line kept as its kind when the curve the import
+/// builds from it ([`build_analytic_edge_curve`]) matches it pointwise at the edge's ends and
+/// along the STEP parameter; anything else NURBS. A curve whose STEP parameter runs against its
+/// NURBS one goes with its axis reversed and its range negated. A call that returns a status
+/// other than success makes the edge NURBS, as there ([`answer`]); any other solidcore error
+/// fails the export.
+fn classify_edge_curve(k: &Kernel, c: Option<&Curve>, ivl: &Extent1d) -> Result {
+ let Some(c) = c else { return Ok(NURBS_EXPORT) };
+ Ok(classify(k, c, ivl).map_err(sc("ClassifyEdgeCurve"))?.unwrap_or(NURBS_EXPORT))
+}
+
+fn classify(k: &Kernel, c: &Curve, ivl: &Extent1d) -> std::result::Result, SolidError> {
+ let mut e = NURBS_EXPORT;
+ match c.kind() {
+ CurveKind::Ellipse(el) => {
+ let can = el.canonical();
+ e.ty = if el.is_circle_class() { CURVE_CIRCLE_I } else { CURVE_ELLIPSE_I };
+ e.center = can.position.origin();
+ e.axis = can.position.z_axis();
+ e.refd = can.position.x_axis();
+ e.r1 = can.radius_x;
+ e.r2 = if el.is_circle_class() { can.radius_x } else { can.radius_y };
+ }
+ CurveKind::Line(l) => {
+ let can = l.canonical();
+ e.ty = CURVE_LINE_I;
+ e.center = can.point;
+ e.axis = can.vector;
+ }
+ _ => return Ok(None),
+ }
+ let mut s0 = {
+ let Some(x) = answer(c.convert_t_from_nurbs_to_step(k, ivl.min()))? else { return Ok(None) };
+ x
+ };
+ let mut s1 = {
+ let Some(x) = answer(c.convert_t_from_nurbs_to_step(k, ivl.max()))? else { return Ok(None) };
+ x
+ };
+ if !s0.is_finite() || !s1.is_finite() {
+ return Ok(None);
+ }
+ // sNew = sign * sOld + shift
+ let (mut sign, mut shift) = (1.0, 0.0);
+ if s0 > s1 {
+ sign = -1.0;
+ s0 = -s0;
+ s1 = -s1;
+ e.axis = Vec3::new(-e.axis.x, -e.axis.y, -e.axis.z);
+ }
+ if !(s1 > s0) {
+ return Ok(None);
+ }
+ if e.ty == CURVE_LINE_I {
+ if s0.abs() > INFINITE_PARAMETER || s1.abs() > INFINITE_PARAMETER {
+ return Ok(None);
+ }
+ (e.lo, e.sm_lo, e.hi, e.sm_hi) = (s0, s0, s1, s1);
+ } else {
+ // SmCircle/SmEllipse::CreateCanonical take intervals within [-360, 360] degrees. (Not
+ // in the C++: angles past a million degrees are refused, bounding the loops below.)
+ if s1 - s0 > 360.0 + 1e-9 || s0.abs() > 1.0e6 || s1.abs() > 1.0e6 {
+ return Ok(None);
+ }
+ while s1 > 360.0 + 1e-9 {
+ s0 -= 360.0;
+ s1 -= 360.0;
+ shift -= 360.0;
+ }
+ while s0 < -360.0 - 1e-9 {
+ s0 += 360.0;
+ s1 += 360.0;
+ shift += 360.0;
+ }
+ if s1 > 360.0 + 1e-9 {
+ return Ok(None);
+ }
+ e.lo = deg2rad(s0);
+ e.hi = deg2rad(s1);
+ // exactly what the import computes
+ e.sm_lo = radians_to_smlib_degrees(e.lo);
+ e.sm_hi = radians_to_smlib_degrees(e.hi);
+ }
+
+ let built = {
+ let Some(x) = answer(analytic_edge_curve(k, e.ty, e.sm_lo, e.sm_hi, e.center, e.axis, e.refd, e.r1, e.r2).map_err(|(_, e)| e))? else {
+ return Ok(None);
+ };
+ x
+ };
+ let a = {
+ let Some(x) = answer(c.evaluate_point(k, ivl.min()))? else { return Ok(None) };
+ x
+ };
+ let b = {
+ let Some(x) = answer(c.evaluate_point(k, ivl.max()))? else { return Ok(None) };
+ x
+ };
+ let tol = 1e-9 * (1.0 + e.r1 + e.r2 + a.max_dimension() + b.max_dimension());
+ let mut affine = true;
+ for step in 0..=8 {
+ let f = f64::from(step) / 8.0;
+ // the same point by the STEP parameter of each curve
+ let s_new = e.sm_lo + f * (e.sm_hi - e.sm_lo);
+ let s_old = (s_new - shift) * sign;
+ let t_old = {
+ let Some(x) = answer(c.convert_t_from_step_to_nurbs(k, s_old))? else { return Ok(None) };
+ x
+ };
+ let t_new = {
+ let Some(x) = answer(built.convert_t_from_step_to_nurbs(k, s_new))? else { return Ok(None) };
+ x
+ };
+ let p_old = {
+ let Some(x) = answer(c.evaluate_point(k, t_old))? else { return Ok(None) };
+ x
+ };
+ let p_new = {
+ let Some(x) = answer(built.evaluate_point(k, t_new))? else { return Ok(None) };
+ x
+ };
+ if !(p_old.distance(p_new) <= tol) {
+ return Ok(None);
+ }
+ // the same point by each edge's NURBS parameter, affinely matched
+ let q_old = {
+ let Some(x) = answer(c.evaluate_point(k, ivl.min() + f * (ivl.max() - ivl.min())))? else { return Ok(None) };
+ x
+ };
+ let q_new = {
+ let Some(x) = answer(built.evaluate_point(k, e.sm_lo + f * (e.sm_hi - e.sm_lo)))? else { return Ok(None) };
+ x
+ };
+ let d = q_old.distance(q_new);
+ if (step == 0 || step == 8) && !(d <= tol) {
+ return Ok(None); // the ends must match exactly as the edge's vertices do
+ }
+ affine = affine && d <= tol;
+ }
+ e.affine = affine;
+ Ok(Some(e))
+}
+
+/// `WriteEdgeCurve`: one edge's kind, range and geometry. A circle or ellipse also goes with its
+/// own NURBS form (`*_NURBS_FORM`), whose interval its range is.
+fn write_edge_curve(k: &Kernel, o: &mut Out, ids: &EdgeIds, e: &EdgeExport, c: Option<&Curve>, ivl: &Extent1d, approx_tol: f64) -> Result<()> {
+ o.i(ids.ty).push(e.ty);
+ let form = e.ty == CURVE_CIRCLE_I || e.ty == CURVE_ELLIPSE_I;
+ o.i(ids.form).push(i64::from(form));
+ let c = c.ok_or_else(|| bad("an edge without a curve"))?;
+ let nurbs = |o: &mut Out, part: &BSplineCurve| -> Result<()> {
+ let (mut count, mut order, mut cvs, mut knots, mut weights) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
+ add_bspline_curve_3d(
+ nurb_of(part)?,
+ NurbsOut { count: &mut count, order: &mut order, cvs: &mut cvs, knots: &mut knots, weights: &mut weights },
+ )?;
+ o.i(ids.count).append(&mut count);
+ o.i(ids.order).append(&mut order);
+ o.r(ids.cvs).append(&mut cvs);
+ o.r(ids.knots).append(&mut knots);
+ o.r(ids.weights).append(&mut weights);
+ Ok(())
+ };
+ if e.ty == CURVE_NURBS_I {
+ o.r(ids.range).extend([ivl.min(), ivl.max()]);
+ // SmuConvert asserts and dereferences NULL
+ let part = edge_curve_as_bspline(k, c, ivl, approx_tol)?.ok_or_else(|| bad("an edge curve SMLib could not approximate"))?;
+ return nurbs(o, &part);
+ }
+ if form {
+ o.r(ids.range).extend([ivl.min(), ivl.max()]);
+ } else {
+ o.r(ids.range).extend([e.lo, e.hi]);
+ }
+ if e.ty == CURVE_LINE_I {
+ o.r3(ids.line_origin, e.center);
+ o.r3(ids.line_direction, e.axis);
+ return Ok(());
+ }
+ if e.ty == CURVE_CIRCLE_I {
+ o.r3(ids.circle_center, e.center);
+ o.r3(ids.circle_axis, e.axis);
+ o.r3(ids.circle_ref, e.refd);
+ o.r(ids.circle_radius).push(e.r1);
+ } else {
+ o.r3(ids.ellipse_center, e.center);
+ o.r3(ids.ellipse_axis, e.axis);
+ o.r3(ids.ellipse_ref, e.refd);
+ o.r(ids.ellipse_x).push(e.r1);
+ o.r(ids.ellipse_y).push(e.r2);
+ }
+ // every SMLib conic is an SmBSplineCurve: its own NURBS, in edge order with the NURBS
+ // edges' curves
+ let part = bspline_curve_part(k, c)?.ok_or_else(|| bad("a conic that is not a B-spline"))?;
+ nurbs(o, &part)
+}
+
+/// `ZoneTolWithFaces`: `own` or, where larger, the B-rep's tolerance or that of a face round it.
+fn zone_tol_with_faces(b: &Brep, own: f64, brep_tol: f64, faces: &[FaceId]) -> Result {
+ let mut tol = own.max(brep_tol);
+ for &f in faces {
+ let t = solidcore::topology::face::p2::tolerance(b, f).map_err(sc("SmFace::GetTolerance"))?;
+ if t.is_finite() && t > tol {
+ tol = t;
+ }
+ }
+ Ok(tol)
+}
+
+/// `GetMaxZoneTol3d`: the largest of the B-rep's, its faces', edges' and vertices' tolerances.
+fn max_zone_tol(b: &Brep) -> Result {
+ let mut max = b.tolerance();
+ let mut take = |t: f64| {
+ if t.is_finite() && t >= 0.0 && t > max {
+ max = t;
+ }
+ };
+ for f in bl::faces(b, None).map_err(sc("GetFaces"))? {
+ take(solidcore::topology::face::p2::tolerance(b, f).map_err(sc("SmFace::GetTolerance"))?);
+ }
+ for e in bl::edges(b, None).map_err(sc("GetEdges"))? {
+ take(edge::tolerance(b, e).map_err(sc("SmEdge::GetTolerance"))?);
+ }
+ for v in bl::vertices(b, None).map_err(sc("GetVertices"))? {
+ take(vertex::tolerance(b, v).map_err(sc("SmVertex::GetTolerance"))?);
+ }
+ Ok(max)
+}
+
+/// The shells in `SmBrepData::FromBrep`'s order: the regions, the infinite one first, each
+/// region's shells in list order.
+fn shells_in_order(b: &Brep) -> Result> {
+ let mut regions = bl::regions(b, None).map_err(sc("GetRegions"))?;
+ let infinite = b.infinite_region();
+ if regions.len() > 1
+ && regions.first().copied() != infinite
+ && let Some(i) = regions.iter().skip(1).position(|&r| Some(r) == infinite)
+ {
+ let r = regions.remove(i + 1);
+ regions.insert(0, r);
+ }
+ let mut shells = Vec::new();
+ for r in regions {
+ shells.extend(b.region(r).map_err(sc("GetShells"))?.shells.iter().copied());
+ }
+ Ok(shells)
+}
+
+/// The topology identity [`BrepData::from_brep`] and [`make_topology_from_data`] name entities
+/// by (upstream: their addresses): the type code and the entity's index.
+pub(crate) fn key_of(e: EntityRef) -> ObjectRef {
+ let (type_code, kind, i) = match e {
+ EntityRef::Brep(x) => (16011, 0u64, x.index()),
+ EntityRef::Region(x) => (16012, 1, x.index()),
+ EntityRef::Shell(x) => (16013, 2, x.index()),
+ EntityRef::Faceuse(x) => (16022, 3, x.index()),
+ EntityRef::Face(x) => (16002, 4, x.index()),
+ EntityRef::Loopuse(x) => (16004, 5, x.index()),
+ EntityRef::Loop(x) => (16003, 6, x.index()),
+ EntityRef::Edgeuse(x) => (16005, 7, x.index()),
+ EntityRef::Edge(x) => (16016, 8, x.index()),
+ EntityRef::Vertexuse(x) => (16006, 9, x.index()),
+ EntityRef::Vertex(x) => (16021, 10, x.index()),
+ };
+ ObjectRef { type_code, key: ObjectKey(((kind + 1) << 32) | u64::from(i)) }
+}
+
+/// `FlatFromBrep`: `b` as flat arrays, through `SmBrepData::FromBrep`. Every edgeuse on a face
+/// without a UV trim first gets SMLib's own (`GetOrCreateUVTrimCurve`); edges and wire edges,
+/// vertices and point-shell vertices, edgeuses and wire edgeuses are split apart, every index
+/// compacted for the split.
+fn flat_from_brep(k: &mut Kernel, b: &mut Brep) -> Result {
+ for eu in solidcore::topology::brep::p2::edgeuses(b, None).map_err(sc("GetEdgeuses"))? {
+ if eu1::faceuse(b, eu).map_err(sc("GetFaceuse"))?.is_none()
+ || eu1::uv_trim_curve_pointer(b, eu).map_err(sc("GetUVTrimCurvePointer"))?.is_some()
+ {
+ continue;
+ }
+ let made = eu3::get_or_create_uv_trim_curve(k, b, eu, false, false, true).map_err(sc("GetOrCreateUVTrimCurve"))?;
+ made.status.map_err(sc("GetOrCreateUVTrimCurve"))?;
+ }
+ let b: &Brep = b;
+
+ // the BrepData; its records keep the B-rep's lists' order
+ 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),
+ };
+ let bd = BrepData::from_brep(b, &env, &mut Vec::new()).map_err(sc("SmBrepData::FromBrep"))?;
+ let shell_ids = shells_in_order(b)?;
+ let face_ids = bl::faces(b, None).map_err(sc("GetFaces"))?;
+ let edge_ids = bl::edges(b, None).map_err(sc("GetEdges"))?;
+ let vertex_ids = bl::vertices(b, None).map_err(sc("GetVertices"))?;
+ if shell_ids.len() != bd.shells.len()
+ || face_ids.len() != bd.faces.len()
+ || edge_ids.len() != bd.edges.len()
+ || vertex_ids.len() != bd.vertices.len()
+ {
+ return Err(bad("SmBrepData::FromBrep's lists differ from the B-rep's"));
+ }
+
+ // TOLERANCE is the B-rep's own tolerance, and each edge and vertex carries its own
+ // (EDGE_TOLERANCE, VERTEX_TOLERANCE). Writing the largest one there instead, as SMLib's
+ // `FlatFromBrep` does, makes it every entity's on import (MakeTopologyFromData raises each
+ // entity to the B-rep's), so every crossing between the kernels spread the largest tolerance
+ // over the body. Curves are still approximated to the largest.
+ let zone = max_zone_tol(b)?;
+ let brep_tol = b.tolerance();
+ let xsect = 2.0 * if brep_tol.is_finite() && brep_tol > 0.0 && brep_tol < zone { brep_tol } else { zone };
+ let approx_tol = zone / 2.0;
+ let bbox = calculate_bounding_box(k, b, BoundingBoxOptions { approx: false, tight: false, expand_by_zone_tol_3d: false })
+ .map_err(sc("CalculateBoundingBox"))?;
+
+ let mut o = Out::new();
+ o.r(TOLERANCE).push(xsect);
+ let (lo, hi) = (bbox.min(), bbox.max());
+ o.r(EXTENT).extend([lo.x, lo.y, lo.z, hi.x, hi.y, hi.z]);
+ o.i(SOURCE).push(1);
+
+ let edge_index: HashMap = edge_ids.iter().enumerate().map(|(i, &e)| (e, i)).collect();
+ let edge_curve = |i: usize| -> Result> { edge::curve(b, get(&edge_ids, i)?).map_err(sc("SmEdge::GetCurve")) };
+ let mut exports = Vec::with_capacity(bd.edges.len());
+ for (i, ed) in bd.edges.iter().enumerate() {
+ exports.push(classify_edge_curve(k, edge_curve(i)?, &ed.interval)?);
+ }
+
+ // Regions
+ for r in &bd.regions {
+ o.i(REGION_SHELL_COUNT).push(i64::try_from(r.num_shells).map_err(|_| bad("count"))?);
+ o.i(REGION_TYPE).push(i64::from(r.is_void != 0));
+ }
+
+ // Shells, wire edges and their curves, point-shell points
+ let mut point_shell_vertices: Vec = Vec::new();
+ let mut wire_edges: Vec = Vec::new();
+ let mut wire_vertices_raw: Vec = Vec::new();
+ for (sd, &s) in bd.shells.iter().zip(&shell_ids) {
+ let sh = b.shell(s).map_err(sc("GetShell"))?;
+ let faceuses = sh.get_faceuses().map_err(sc("SmShell::GetFaceuses"))?.len();
+ let wires = shell::wire_edges(k, b, s).map_err(sc("SmShell::GetWireEdges"))?;
+ let point = faceuses == 0 && wires.is_empty();
+ o.i(SHELL_FACEUSE_COUNT).push(count(faceuses)?);
+ o.i(SHELL_WIRE_EDGE_COUNT).push(count(wires.len())?);
+ o.i(SHELL_POINT_TYPE).push(i64::from(point));
+ for w in wires {
+ let i = *edge_index.get(&w).ok_or_else(|| bad("a wire edge not in the B-rep"))?;
+ let wd = bd.edges.get(i).ok_or_else(|| bad("wire edge record"))?;
+ wire_edges.push(i as u64);
+ wire_vertices_raw.extend([wd.start_vertex, wd.end_vertex]);
+ write_edge_curve(k, &mut o, &WIRE_EDGES, &get(&exports, i)?, edge_curve(i)?, &wd.interval, approx_tol)?;
+ }
+ if point {
+ let v = sh.vertex(b).map_err(sc("SmShell::GetVertex"))?.ok_or_else(|| bad("a point shell without its vertex"))?;
+ point_shell_vertices.push(sd.vertex);
+ let p = vertex::point(b, v).map_err(sc("SmVertex::GetPoint"))?;
+ o.r3(SHELL_POINT_POSITION, p);
+ }
+ }
+ point_shell_vertices.sort_unstable();
+ wire_edges.sort_unstable();
+ for v in wire_vertices_raw {
+ let c = compact_index(v, &point_shell_vertices)?;
+ o.i(WIRE_EDGE_VERTICES).push(c);
+ }
+
+ // Faceuses
+ for fu in &bd.faceuses {
+ o.i(FACEUSE_FACE).push(i64::try_from(fu.face).map_err(|_| bad("faceuse face"))?);
+ o.i(FACEUSE_ORIENTATION).push(i64::from(fu.orientation == 1));
+ }
+
+ // Faces and their surfaces
+ let n_loops = bd.loops.len();
+ let mut loop_exports_uv = vec![false; n_loops];
+ let mut loop_plane_map: Vec> = vec![None; n_loops];
+ for (fd, &f) in bd.faces.iter().zip(&face_ids) {
+ let surface = b.face(f).map_err(sc("GetFace"))?.surface.as_ref().ok_or_else(|| bad("a face without a surface"))?;
+ // `m_lSurface >= m_vSurfaces.GetSize()`: a surface no face of the B-rep owns.
+ if !usize::try_from(fd.surface).is_ok_and(|s| s < bd.surfaces.len()) {
+ return Err(bad("a face whose surface its B-rep does not hold"));
+ }
+ let trim_type = i64::from(fd.rectangular_trim == 1);
+ let ty = match surface.kind() {
+ SurfaceKind::Sphere(_) => SURFACE_SPHERE_I,
+ SurfaceKind::Plane(_) => SURFACE_PLANE_I,
+ SurfaceKind::Cone(c) if c.is_cylinder_class() => SURFACE_CYLINDER_I,
+ SurfaceKind::Cone(_) => SURFACE_CONE_I,
+ SurfaceKind::Torus(_) => SURFACE_TORUS_I,
+ _ => SURFACE_NURBS_I,
+ };
+ // A curved analytic face goes with its NURBS form, whose parameters its range and trims
+ // are in; a plane's (u, v) are distances along its unit axes, an affine image of its
+ // NURBS (u, v).
+ let nurbs_form = ty != SURFACE_NURBS_I && ty != SURFACE_PLANE_I;
+ let mut plane_map = None;
+ if let SurfaceKind::Plane(p) = surface.kind() {
+ let scale = p.uv_scale();
+ let mut q = [Point2::new(0.0, 0.0); 3];
+ let mut ok = true;
+ for (qk, at) in q.iter_mut().zip([Point2::new(0.0, 0.0), Point2::new(1.0, 0.0), Point2::new(0.0, 1.0)]) {
+ match answer(surface.convert_uv_from_nurbs_to_step(k, at)).map_err(sc("SmSurface::ConvertUVFromNURBSToSTEP"))? {
+ Some(st) => *qk = Point2::new(st.x * scale.x, st.y * scale.y),
+ None => {
+ ok = false;
+ break;
+ }
+ }
+ }
+ if ok {
+ let [q0, q1, q2] = q;
+ plane_map = Some([q0.x, q1.x - q0.x, q2.x - q0.x, q0.y, q1.y - q0.y, q2.y - q0.y]);
+ }
+ }
+ for l in 0..fd.num_loops {
+ let li = index_u64(fd.start_loop.saturating_add(l))?;
+ let (Some(x), Some(m)) = (loop_exports_uv.get_mut(li), loop_plane_map.get_mut(li)) else {
+ return Err(bad("face loop index is out of bounds"));
+ };
+ *x = ty == SURFACE_NURBS_I || nurbs_form || plane_map.is_some();
+ *m = plane_map;
+ }
+
+ // face range: a NURBS (or NURBS form) face's native (u, v); an analytic surface's STEP
+ // domain, angles in radians
+ let d = fd.uv_domain;
+ let (mut umin, mut vmin, mut umax, mut vmax) = (d.u_min(), d.v_min(), d.u_max(), d.v_max());
+ if ty != SURFACE_NURBS_I && !nurbs_form {
+ let step = surface.step_uv_domain();
+ if ty == SURFACE_SPHERE_I || ty == SURFACE_TORUS_I {
+ (umin, vmin, umax, vmax) = (deg2rad(step.u_min()), deg2rad(step.v_min()), deg2rad(step.u_max()), deg2rad(step.v_max()));
+ } else if ty == SURFACE_CYLINDER_I || ty == SURFACE_CONE_I {
+ (umin, vmin, umax, vmax) = (deg2rad(step.u_min()), step.v_min(), deg2rad(step.u_max()), step.v_max());
+ } else if let SurfaceKind::Plane(p) = surface.kind() {
+ // plane parameters are distances along unit placement axes (SmExtent2d::Scale)
+ let s = p.uv_scale();
+ let scale = |x: f64, by: f64| if is_infinite(x) { x } else { x * by };
+ (umin, vmin, umax, vmax) = (scale(step.u_min(), s.x), scale(step.v_min(), s.y), scale(step.u_max(), s.x), scale(step.v_max(), s.y));
+ }
+ }
+ o.i(FACE_LOOP_COUNT).push(i64::try_from(fd.num_loops).map_err(|_| bad("count"))?);
+ o.i(FACE_SURFACE_TYPE).push(ty);
+ o.i(FACE_TRIM_TYPE).push(trim_type);
+ o.i(FACE_NURBS_FORM).push(i64::from(nurbs_form));
+ o.r(FACE_RANGE).extend([umin, vmin, umax, vmax]);
+
+ match surface.kind() {
+ SurfaceKind::Sphere(s) => {
+ let pos = s.base().position();
+ o.r3(FACE_SPHERE_CENTER, pos.origin());
+ o.r3(FACE_SPHERE_AXIS, pos.z_axis());
+ o.r3(FACE_SPHERE_REF_DIRECTION, pos.x_axis());
+ o.r(FACE_SPHERE_RADIUS).push(s.radius());
+ }
+ SurfaceKind::Plane(p) => {
+ let pos = p.position();
+ o.r3(FACE_PLANE_ORIGIN, pos.origin());
+ o.r3(FACE_PLANE_AXIS, pos.z_axis());
+ o.r3(FACE_PLANE_REF_DIRECTION, pos.x_axis());
+ }
+ SurfaceKind::Cone(c) if c.is_cylinder_class() => {
+ let pos = c.canonical().origin;
+ o.r3(FACE_CYLINDER_ORIGIN, pos.origin());
+ o.r3(FACE_CYLINDER_AXIS, pos.z_axis());
+ o.r3(FACE_CYLINDER_REF_DIRECTION, pos.x_axis());
+ o.r(FACE_CYLINDER_RADIUS).push(c.bot_radius());
+ }
+ SurfaceKind::Cone(c) => {
+ let can = c.canonical();
+ o.r3(FACE_CONE_ORIGIN, can.origin.origin());
+ o.r3(FACE_CONE_AXIS, can.origin.z_axis());
+ o.r3(FACE_CONE_REF_DIRECTION, can.origin.x_axis());
+ o.r(FACE_CONE_RADIUS).push(can.radius);
+ o.r(FACE_CONE_SEMI_ANGLE).push(deg2rad(can.semi_angle_deg));
+ }
+ SurfaceKind::Torus(t) => {
+ let pos = t.base().position();
+ o.r3(FACE_TORUS_ORIGIN, pos.origin());
+ o.r3(FACE_TORUS_AXIS, pos.z_axis());
+ o.r3(FACE_TORUS_REF_DIRECTION, pos.x_axis());
+ o.r(FACE_TORUS_MAJOR_RADIUS).push(t.major_radius());
+ o.r(FACE_TORUS_MINOR_RADIUS).push(t.minor_radius());
+ }
+ _ => {}
+ }
+ if nurbs_form || ty == SURFACE_NURBS_I {
+ // Every analytic class is an SmBSplineSurface: its own NURBS, in face order with the
+ // NURBS faces' surfaces. Another surface (an offset) is approximated within the
+ // curves' tolerance, splitting its spans up to 5 times; its status fails the export,
+ // as does no surface, which would leave the arrays without this face's NURBS.
+ let approximated;
+ let part = match bspline_surface_part(k, surface)? {
+ Some(part) => part,
+ None => {
+ approximated = surface
+ .approximate_surface(k, approx_tol3d(k, approx_tol), 5, true)
+ .map_err(sc("SmSurface::ApproximateSurface"))?
+ .surface
+ .ok_or_else(|| bad("a face surface SMLib could not convert to a B-spline"))?;
+ bspline_surface_part(k, &approximated)?.ok_or_else(|| bad("an approximated face surface that is not a B-spline"))?
+ }
+ };
+ add_bspline_surface(&mut o, part.gw_nurb().ok_or_else(|| bad("a B-spline surface without its NURB"))?)?;
+ }
+ }
+
+ // Loops
+ for l in &bd.loops {
+ if l.loop_type == 0 && l.num_eu > 0 {
+ o.i(LOOP_EDGEUSE_COUNT).push(i64::try_from(l.num_eu).map_err(|_| bad("count"))?);
+ o.i(LOOP_VERTEX).push(-1);
+ } else if l.loop_type == 1 && l.vertex != NO_OBJECT && l.vertex != UNDEF_ULONG {
+ o.i(LOOP_EDGEUSE_COUNT).push(0);
+ let c = compact_index(l.vertex, &point_shell_vertices)?;
+ o.i(LOOP_VERTEX).push(c);
+ } else {
+ return Err(bad("incorrect loop type"));
+ }
+ }
+
+ // Edgeuses connecting edges to faces; wire edges' edgeuses are removed. A pair's radial
+ // entry: 1 when its top edgeuse's next is its own bottom one.
+ let non_face: Vec = bd.edgeuses.iter().enumerate().filter(|(_, e)| e.eu_type != 0).map(|(i, _)| i as u64).collect();
+ for (j, eu) in bd.edgeuses.iter().enumerate() {
+ if eu.eu_type != 0 {
+ continue;
+ }
+ let j = count(j)?;
+ o.i(EDGEUSE_EDGE).push(compact_index(eu.edge, &wire_edges)?);
+ o.i(EDGEUSE_ORIENTATION).push(i64::from(eu.orientation == 1));
+ let top_next = if eu.next_eu >= 0 { eu.next_eu } else { -eu.next_eu - 1 };
+ let bot_next = if eu.mate_next_eu >= 0 { eu.mate_next_eu } else { -eu.mate_next_eu - 1 };
+ let top_entry = top_next == j;
+ o.i(EDGEUSE_RADIAL_ENTRY).push(i64::from(top_entry));
+ let next = u64::try_from(if top_entry { bot_next } else { top_next }).map_err(|_| bad("radial index"))?;
+ let next = compact_index(next, &non_face)?;
+ o.i(EDGEUSE_NEXT_RADIAL).push(next);
+
+ // the UV trim, on faces whose loops carry them
+ let li = index_u64(eu.loop_)?;
+ let exports_uv = *loop_exports_uv.get(li).ok_or_else(|| bad("edgeuse loop index is out of bounds"))?;
+ let trim = if exports_uv && eu.uv_curve != NO_OBJECT { bd.uv_curves.get(index_u64(eu.uv_curve)?) } else { None };
+ let part = match trim {
+ Some(t) => bspline_curve_part(k, &t.object)?,
+ None => None,
+ };
+ let mut nurb = part.as_deref().map(nurb_of).transpose()?;
+ // On an edge exported as a line, the trim's knots follow the line's parameter, or the
+ // trim is dropped where that is not an affine image of the edge's (SMLib rebuilds it).
+ // Circles and ellipses go out on their NURBS form, whose parameter the trims are in.
+ let mut remap = None;
+ let ei = index_u64(eu.edge)?;
+ if nurb.is_some()
+ && let Some(ex) = exports.get(ei)
+ && ex.ty == CURVE_LINE_I
+ {
+ if ex.affine {
+ let ivl = bd.edges.get(ei).ok_or_else(|| bad("edge record"))?.interval;
+ remap = Some([ivl.min(), ivl.max(), ex.sm_lo, ex.sm_hi]);
+ } else {
+ nurb = None;
+ }
+ }
+ let affine = loop_plane_map.get(li).copied().flatten();
+ let before = o.i(EDGEUSE_NURBS_VERTEX_COUNT).len();
+ if add_bspline_curve_2d(&mut o, nurb, eu.orientation == 1, remap, affine).is_err() && o.i(EDGEUSE_NURBS_VERTEX_COUNT).len() == before {
+ // UV trims are optional: keep the edgeuse / trim records one to one
+ o.i(EDGEUSE_NURBS_VERTEX_COUNT).push(0);
+ o.i(EDGEUSE_NURBS_ORDER).push(0);
+ }
+ }
+
+ // Edges (face edges; wire edges went with their shells)
+ for (j, ed) in bd.edges.iter().enumerate() {
+ if wire_edges.binary_search(&(j as u64)).is_ok() {
+ continue;
+ }
+ let e = get(&edge_ids, j)?;
+ let start = compact_index(ed.start_vertex, &point_shell_vertices)?;
+ let end = compact_index(ed.end_vertex, &point_shell_vertices)?;
+ o.i(EDGE_VERTICES).extend([start, end]);
+ let faces = edge::faces(b, e, None).map_err(sc("SmEdge::GetFaces"))?;
+ let own = edge::tolerance(b, e).map_err(sc("SmEdge::GetTolerance"))?;
+ o.r(EDGE_TOLERANCE).push(2.0 * zone_tol_with_faces(b, own, brep_tol, &faces)?);
+ write_edge_curve(k, &mut o, &EDGES, &get(&exports, j)?, edge_curve(j)?, &ed.interval, approx_tol)?;
+ }
+
+ // Vertices (point-shell vertices went with their shells)
+ for (j, vd) in bd.vertices.iter().enumerate() {
+ if point_shell_vertices.binary_search(&(j as u64)).is_ok() {
+ continue;
+ }
+ o.i(VERTEX_POINT_TYPE).push(1);
+ o.r3(VERTEX_POSITION, vd.point);
+ let v: VertexId = get(&vertex_ids, j)?;
+ let faces = vertex::faces(b, v).map_err(sc("SmVertex::GetFaces"))?;
+ let own = vertex::tolerance(b, v).map_err(sc("SmVertex::GetTolerance"))?;
+ o.r(VERTEX_TOLERANCE).push(2.0 * zone_tol_with_faces(b, own, brep_tol, &faces)?);
+ }
+ Ok(o.0)
+}
+
+// ---- import: Validate --------------------------------------------------------------------------
+
+/// The arrays of one B-rep, read as hostile input.
+struct In<'a>(&'a Arrays);
+
+impl<'a> In<'a> {
+ fn i(&self, id: i32) -> &'a [i64] {
+ self.0.ints.get(&id).map_or(&[], Vec::as_slice)
+ }
+ fn r(&self, id: i32) -> &'a [f64] {
+ self.0.reals.get(&id).map_or(&[], Vec::as_slice)
+ }
+}
+
+/// What `Validate` learns about valid input (`Shape`).
+#[derive(Default)]
+struct Shape {
+ regions: usize,
+ shells: usize,
+ faceuses: usize,
+ faces: usize,
+ loops: usize,
+ edgeuses: usize,
+ edges: usize,
+ wire_edges: usize,
+ vertices: usize,
+ point_shells: usize,
+ uv_curves: bool,
+}
+
+/// Below SMLib's index sentinels (`SM_NO_OBJECT`, `MakeTopologyFromData`'s 999999).
+const MAX_TOPOLOGY: i64 = 999_000;
+/// `NL_MAXDEG + 1`.
+const MAX_ORDER: i64 = 18;
+const MAX_VALUE: f64 = 1.0e9;
+const MAX_FLAT_LEN: i64 = 1 << 27;
+
+fn bits(v: &[i64]) -> bool {
+ v.iter().all(|&x| x == 0 || x == 1)
+}
+
+fn in_range(v: &[i64], lo: i64, hi: i64) -> bool {
+ v.iter().all(|&x| x >= lo && x <= hi)
+}
+
+/// `SumCounts`: the sum of non-negative counts, `None` past [`MAX_TOPOLOGY`].
+fn sum_counts(v: &[i64]) -> Option {
+ let mut s = 0i64;
+ for &x in v {
+ if !(0..=MAX_TOPOLOGY).contains(&x) {
+ return None;
+ }
+ s += x;
+ if s > MAX_TOPOLOGY {
+ return None;
+ }
+ }
+ Some(s)
+}
+
+fn has_form(form: &[i64], i: usize) -> bool {
+ form.get(i) == Some(&1)
+}
+
+/// `GoodFrame`: an axis and a reference direction that span a plane.
+fn good_frame(axis: &[f64], refd: &[f64], i: usize) -> bool {
+ let (Ok(a), Ok(r)) = (get3(axis, i), get3(refd, i)) else { return false };
+ let (la, lr) = (a.length(), r.length());
+ la > 1e-12 && lr > 1e-12 && a.cross(r).length() > 1e-9 * la * lr
+}
+
+/// `GoodKnots`: `count + order` knots, non-decreasing, with a non-empty interval.
+fn good_knots(k: &[f64], count: i64, order: i64) -> bool {
+ let (Ok(n), Ok(o)) = (usize::try_from(count), usize::try_from(order)) else { return false };
+ let Some(k) = n.checked_add(o).and_then(|m| k.get(..m)) else { return false };
+ if !k.windows(2).all(|w| matches!(w, [a, b] if b >= a)) {
+ return false;
+ }
+ matches!((o.checked_sub(1).and_then(|i| k.get(i)), k.get(n)), (Some(a), Some(b)) if a < b)
+}
+
+fn good_weights(w: &[f64]) -> bool {
+ w.iter().all(|&x| x > 0.0)
+}
+
+/// `GoodNurbsCurves`: one family of NURBS curves; with `optional`, a curve of count and order 0
+/// is none.
+fn good_nurbs_curves(count: &[i64], order: &[i64], cvs: &[f64], knots: &[f64], weights: &[f64], dim: i64, optional: bool) -> bool {
+ if count.len() != order.len() {
+ return false;
+ }
+ let (mut points, mut knot_total) = (0i64, 0i64);
+ for (&c, &o) in count.iter().zip(order) {
+ if optional && c == 0 && o == 0 {
+ continue;
+ }
+ if !(2..=MAX_ORDER).contains(&o) || c < o || c > MAX_FLAT_LEN {
+ return false;
+ }
+ points += c;
+ knot_total += c + o;
+ if points > MAX_FLAT_LEN || knot_total > MAX_FLAT_LEN {
+ return false;
+ }
+ }
+ if i64::try_from(cvs.len()).ok() != Some(dim * points) || i64::try_from(knots.len()).ok() != Some(knot_total) {
+ return false;
+ }
+ if !weights.is_empty() && (i64::try_from(weights.len()).ok() != Some(points) || !good_weights(weights)) {
+ return false;
+ }
+ let mut k = 0usize;
+ for (&c, &o) in count.iter().zip(order) {
+ if optional && c == 0 {
+ continue;
+ }
+ if !knots.get(k..).is_some_and(|s| good_knots(s, c, o)) {
+ return false;
+ }
+ k += (c + o) as usize;
+ }
+ true
+}
+
+/// One edge family's arrays (`GoodEdges`, `MakeEdgeCurve`).
+struct EdgeIn<'a> {
+ ty: &'a [i64],
+ vertices: &'a [i64],
+ range: &'a [f64],
+ count: &'a [i64],
+ order: &'a [i64],
+ cvs: &'a [f64],
+ knots: &'a [f64],
+ weights: &'a [f64],
+ circle_center: &'a [f64],
+ circle_axis: &'a [f64],
+ circle_ref: &'a [f64],
+ circle_radius: &'a [f64],
+ line_origin: &'a [f64],
+ line_direction: &'a [f64],
+ ellipse_center: &'a [f64],
+ ellipse_axis: &'a [f64],
+ ellipse_ref: &'a [f64],
+ ellipse_x: &'a [f64],
+ ellipse_y: &'a [f64],
+ form: &'a [i64],
+}
+
+impl<'a> EdgeIn<'a> {
+ fn new(f: &In<'a>, ids: &EdgeIds) -> EdgeIn<'a> {
+ EdgeIn {
+ ty: f.i(ids.ty),
+ vertices: f.i(ids.vertices),
+ range: f.r(ids.range),
+ count: f.i(ids.count),
+ order: f.i(ids.order),
+ cvs: f.r(ids.cvs),
+ knots: f.r(ids.knots),
+ weights: f.r(ids.weights),
+ circle_center: f.r(ids.circle_center),
+ circle_axis: f.r(ids.circle_axis),
+ circle_ref: f.r(ids.circle_ref),
+ circle_radius: f.r(ids.circle_radius),
+ line_origin: f.r(ids.line_origin),
+ line_direction: f.r(ids.line_direction),
+ ellipse_center: f.r(ids.ellipse_center),
+ ellipse_axis: f.r(ids.ellipse_axis),
+ ellipse_ref: f.r(ids.ellipse_ref),
+ ellipse_x: f.r(ids.ellipse_x),
+ ellipse_y: f.r(ids.ellipse_y),
+ form: f.i(ids.form),
+ }
+ }
+}
+
+/// `GoodEdges`: an edge family's ranges against its curves.
+fn good_edges(e: &EdgeIn<'_>, n: usize, n_vertices: usize) -> bool {
+ // a NURBS form is given for circles and ellipses only, one flag per edge, or for none
+ if !e.form.is_empty() && (e.form.len() != n || !bits(e.form)) {
+ return false;
+ }
+ if e.form.iter().zip(e.ty).any(|(&f, &t)| f == 1 && t != CURVE_CIRCLE_I && t != CURVE_ELLIPSE_I) {
+ return false;
+ }
+ let Ok(last_vertex) = i64::try_from(n_vertices).map(|v| v - 1) else { return false };
+ if e.ty.len() != n
+ || Some(e.vertices.len()) != n.checked_mul(2)
+ || Some(e.range.len()) != n.checked_mul(2)
+ || !in_range(e.ty, CURVE_NURBS_I, CURVE_ELLIPSE_I)
+ || !in_range(e.vertices, 0, last_vertex)
+ {
+ return false;
+ }
+ let (mut n_n, mut n_c, mut n_l, mut n_e) = (0usize, 0usize, 0usize, 0usize);
+ for (i, &t) in e.ty.iter().enumerate() {
+ n_n += usize::from(t == CURVE_NURBS_I || has_form(e.form, i));
+ n_c += usize::from(t == CURVE_CIRCLE_I);
+ n_l += usize::from(t == CURVE_LINE_I);
+ n_e += usize::from(t == CURVE_ELLIPSE_I);
+ }
+ if e.count.len() != n_n || !good_nurbs_curves(e.count, e.order, e.cvs, e.knots, e.weights, 3, false) {
+ return false;
+ }
+ if e.circle_center.len() != 3 * n_c
+ || e.circle_axis.len() != 3 * n_c
+ || e.circle_ref.len() != 3 * n_c
+ || e.circle_radius.len() != n_c
+ || e.line_origin.len() != 3 * n_l
+ || e.line_direction.len() != 3 * n_l
+ || e.ellipse_center.len() != 3 * n_e
+ || e.ellipse_axis.len() != 3 * n_e
+ || e.ellipse_ref.len() != 3 * n_e
+ || e.ellipse_x.len() != n_e
+ || e.ellipse_y.len() != n_e
+ {
+ return false;
+ }
+ for i in 0..n_c {
+ if !(e.circle_radius.get(i).is_some_and(|&r| r > 0.0)) || !good_frame(e.circle_axis, e.circle_ref, i) {
+ return false;
+ }
+ }
+ for i in 0..n_l {
+ if !get3(e.line_direction, i).is_ok_and(|d| d.length() > 1e-12) {
+ return false;
+ }
+ }
+ for i in 0..n_e {
+ if !(e.ellipse_x.get(i).is_some_and(|&r| r > 0.0) && e.ellipse_y.get(i).is_some_and(|&r| r > 0.0))
+ || !good_frame(e.ellipse_axis, e.ellipse_ref, i)
+ {
+ return false;
+ }
+ }
+ // each edge's range: non-empty; inside its NURBS curve's knot interval; at most one turn
+ // for circles and ellipses (radians)
+ let (mut i_nurbs, mut knot) = (0usize, 0usize);
+ for i in 0..n {
+ let (Some(&lo), Some(&hi)) = (e.range.get(2 * i), e.range.get(2 * i + 1)) else { return false };
+ let t = e.ty.get(i).copied().unwrap_or(-1);
+ if !(lo < hi) {
+ return false;
+ }
+ if t == CURVE_NURBS_I || has_form(e.form, i) {
+ let (Some(&c), Some(&o)) = (e.count.get(i_nurbs), e.order.get(i_nurbs)) else { return false };
+ let (Ok(cu), Ok(ou)) = (usize::try_from(c), usize::try_from(o)) else { return false };
+ let (Some(&k0), Some(&k1)) = (e.knots.get(knot + ou - 1), e.knots.get(knot + cu)) else { return false };
+ let tol = 1e-9 * (k1 - k0).max(1.0);
+ if lo < k0 - tol || hi > k1 + tol {
+ return false;
+ }
+ knot += cu + ou;
+ i_nurbs += 1;
+ } else if (t == CURVE_CIRCLE_I || t == CURVE_ELLIPSE_I)
+ && (hi - lo > std::f64::consts::TAU + 1e-9
+ || lo.abs() > std::f64::consts::TAU * (1.0 + 1e-9)
+ || hi.abs() > std::f64::consts::TAU * (1.0 + 1e-9))
+ {
+ return false; // SmCircle/SmEllipse::CreateCanonical take [-360, 360] degrees
+ } else if t == CURVE_LINE_I && (lo.abs() > INFINITE_PARAMETER || hi.abs() > INFINITE_PARAMETER) {
+ return false; // an imported line is unbounded over +-SM_INFINITE_PARAMETER
+ }
+ }
+ true
+}
+
+fn face_has_nurbs(f: &In<'_>, i: usize) -> bool {
+ f.i(FACE_SURFACE_TYPE).get(i) == Some(&SURFACE_NURBS_I) || has_form(f.i(FACE_NURBS_FORM), i)
+}
+
+/// `Validate`: every check the build relies on; `None` on anything malformed.
+fn validate(f: &In<'_>) -> Option {
+ let mut s = Shape::default();
+ // every double is finite and of sane magnitude
+ if f.0.reals.values().flatten().any(|x| !x.is_finite() || x.abs() > MAX_VALUE) {
+ return None;
+ }
+
+ // B-rep data
+ let tol = f.r(TOLERANCE);
+ if tol.len() != 1 || !tol.iter().all(|&t| t > 0.0 && t <= 1.0) {
+ return None;
+ }
+ if !f.r(EXTENT).is_empty() && f.r(EXTENT).len() != 6 {
+ return None;
+ }
+ if f.i(SOURCE).len() > 1 || !bits(f.i(SOURCE)) {
+ return None;
+ }
+
+ // Regions
+ s.regions = f.i(REGION_SHELL_COUNT).len();
+ let shells = sum_counts(f.i(REGION_SHELL_COUNT))?;
+ if s.regions < 1 || s.regions > MAX_TOPOLOGY as usize || f.i(REGION_TYPE).len() != s.regions || !bits(f.i(REGION_TYPE)) {
+ return None;
+ }
+ s.shells = shells as usize;
+
+ // Shells: a faceuse shell, a wire-edge shell (one edge) or a point shell
+ let (fu_count, we_count, pt_type) = (f.i(SHELL_FACEUSE_COUNT), f.i(SHELL_WIRE_EDGE_COUNT), f.i(SHELL_POINT_TYPE));
+ if fu_count.len() != s.shells || we_count.len() != s.shells || pt_type.len() != s.shells || !bits(we_count) || !bits(pt_type) {
+ return None;
+ }
+ let faceuses = sum_counts(fu_count)?;
+ if faceuses % 2 != 0 {
+ return None;
+ }
+ for i in 0..s.shells {
+ let (fu, we, pt) = (fu_count.get(i)?, we_count.get(i)?, pt_type.get(i)?);
+ if *fu > 0 {
+ if *we != 0 {
+ return None;
+ }
+ } else if *we == 0 {
+ if *pt != 1 {
+ return None;
+ }
+ s.point_shells += 1;
+ }
+ }
+ s.wire_edges = sum_counts(we_count)? as usize;
+ if f.r(SHELL_POINT_POSITION).len() != 3 * s.point_shells {
+ return None;
+ }
+
+ // Faceuses: each face has exactly two, one on each side
+ s.faceuses = faceuses as usize;
+ s.faces = s.faceuses / 2;
+ let (fu_face, fu_orient) = (f.i(FACEUSE_FACE), f.i(FACEUSE_ORIENTATION));
+ if fu_face.len() != s.faceuses || fu_orient.len() != s.faceuses || !bits(fu_orient) || !in_range(fu_face, 0, s.faces as i64 - 1) {
+ return None;
+ }
+ let mut sides = vec![0u8; s.faces];
+ for (&face, &o) in fu_face.iter().zip(fu_orient) {
+ let bit = if o == 1 { 1 } else { 2 };
+ let side = sides.get_mut(face as usize)?;
+ if *side & bit != 0 {
+ return None;
+ }
+ *side |= bit;
+ }
+
+ // Faces
+ let (loop_count, surface_type, trim_type, range) = (f.i(FACE_LOOP_COUNT), f.i(FACE_SURFACE_TYPE), f.i(FACE_TRIM_TYPE), f.r(FACE_RANGE));
+ if loop_count.len() != s.faces
+ || surface_type.len() != s.faces
+ || trim_type.len() != s.faces
+ || range.len() != 4 * s.faces
+ || !bits(trim_type)
+ || !in_range(surface_type, SURFACE_NURBS_I, SURFACE_TORUS_I)
+ || !in_range(loop_count, 1, MAX_TOPOLOGY)
+ {
+ return None;
+ }
+ let form = f.i(FACE_NURBS_FORM);
+ if !form.is_empty() {
+ if form.len() != s.faces || !bits(form) {
+ return None;
+ }
+ if form.iter().zip(surface_type).any(|(&fm, &t)| fm == 1 && (t == SURFACE_NURBS_I || t == SURFACE_PLANE_I)) {
+ return None;
+ }
+ }
+ for i in 0..s.faces {
+ let (u0, v0, u1, v1) = (*range.get(4 * i)?, *range.get(4 * i + 1)?, *range.get(4 * i + 2)?, *range.get(4 * i + 3)?);
+ if !(u0 < u1) || !(v0 < v1) {
+ return None;
+ }
+ // angular parameters (radians): at most one turn, near the principal range; a sphere's
+ // latitude within [-pi/2, pi/2]. A face given with its NURBS form has a NURBS range.
+ let t = if face_has_nurbs(f, i) { SURFACE_NURBS_I } else { *surface_type.get(i)? };
+ let u_angle = t == SURFACE_SPHERE_I || t == SURFACE_TORUS_I || t == SURFACE_CYLINDER_I || t == SURFACE_CONE_I;
+ let v_angle = t == SURFACE_SPHERE_I || t == SURFACE_TORUS_I;
+ let pi = std::f64::consts::PI;
+ let (turn, far) = (2.0 * pi * (1.0 + 1e-9), 4.0 * pi);
+ if (u_angle && (u1 - u0 > turn || u0.abs() > far || u1.abs() > far))
+ || (v_angle && (v1 - v0 > turn || v0.abs() > far || v1.abs() > far))
+ || (t == SURFACE_SPHERE_I && (v0 < -0.5 * pi - 1e-9 || v1 > 0.5 * pi + 1e-9))
+ {
+ return None;
+ }
+ }
+ s.loops = sum_counts(loop_count)? as usize;
+
+ // Vertices
+ s.vertices = f.i(VERTEX_POINT_TYPE).len();
+ if s.vertices > MAX_TOPOLOGY as usize
+ || s.vertices + s.point_shells > MAX_TOPOLOGY as usize
+ || !in_range(f.i(VERTEX_POINT_TYPE), 1, 1)
+ || f.r(VERTEX_POSITION).len() != 3 * s.vertices
+ {
+ return None;
+ }
+ // per-edge and per-vertex tolerances: none, or one each
+ let tolerances_ok = |t: &[f64], n: usize| t.is_empty() || (t.len() == n && t.iter().all(|&x| x.is_finite() && x > 0.0 && x <= 1.0));
+ if !tolerances_ok(f.r(VERTEX_TOLERANCE), s.vertices) {
+ return None;
+ }
+
+ // Loops
+ let (lp_count, lp_vertex) = (f.i(LOOP_EDGEUSE_COUNT), f.i(LOOP_VERTEX));
+ if lp_count.len() != s.loops || lp_vertex.len() != s.loops {
+ return None;
+ }
+ s.edgeuses = sum_counts(lp_count)? as usize;
+ for (&n, &v) in lp_count.iter().zip(lp_vertex) {
+ if if n == 0 { !(v >= 0 && v < s.vertices as i64) } else { v != -1 } {
+ return None;
+ }
+ }
+
+ // Edges and wire edges
+ s.edges = f.i(EDGE_CURVE_TYPE).len();
+ if s.edges > MAX_TOPOLOGY as usize || s.edges + s.wire_edges > MAX_TOPOLOGY as usize || s.wire_edges + s.edgeuses > MAX_TOPOLOGY as usize {
+ return None;
+ }
+ if !tolerances_ok(f.r(EDGE_TOLERANCE), s.edges) {
+ return None;
+ }
+ if !good_edges(&EdgeIn::new(f, &EDGES), s.edges, s.vertices) || !good_edges(&EdgeIn::new(f, &WIRE_EDGES), s.wire_edges, s.vertices) {
+ return None;
+ }
+
+ // Edgeuses
+ let (eu_edge, eu_orient, eu_next, eu_entry) = (f.i(EDGEUSE_EDGE), f.i(EDGEUSE_ORIENTATION), f.i(EDGEUSE_NEXT_RADIAL), f.i(EDGEUSE_RADIAL_ENTRY));
+ if eu_edge.len() != s.edgeuses
+ || eu_orient.len() != s.edgeuses
+ || eu_next.len() != s.edgeuses
+ || eu_entry.len() != s.edgeuses
+ || !bits(eu_orient)
+ || !bits(eu_entry)
+ || !in_range(eu_edge, 0, s.edges as i64 - 1)
+ || !in_range(eu_next, 0, s.edgeuses as i64 - 1)
+ {
+ return None;
+ }
+ {
+ // the radial lists: NEXT_RADIAL is a permutation that stays on each edge and cycles once
+ // through all of each edge's edgeuses; every edge has at least one edgeuse
+ let mut per_edge = vec![0i64; s.edges];
+ let mut hit = vec![false; s.edgeuses];
+ let mut first = vec![-1i64; s.edges];
+ for i in 0..s.edgeuses {
+ let e = *eu_edge.get(i)? as usize;
+ let n = *eu_next.get(i)? as usize;
+ if eu_edge.get(n)? != eu_edge.get(i)? || *hit.get(n)? {
+ return None;
+ }
+ *hit.get_mut(n)? = true;
+ *per_edge.get_mut(e)? += 1;
+ if *first.get(e)? < 0 {
+ *first.get_mut(e)? = i as i64;
+ }
+ }
+ for e in 0..s.edges {
+ let start = *first.get(e)?;
+ if start < 0 {
+ return None;
+ }
+ let (mut steps, mut cur) = (0i64, start);
+ loop {
+ cur = *eu_next.get(cur as usize)?;
+ steps += 1;
+ if cur == start || steps > *per_edge.get(e)? {
+ break;
+ }
+ }
+ if steps != *per_edge.get(e)? {
+ return None;
+ }
+ }
+ }
+
+ // every vertex is used; every edgeuse loop is a closed chain of its edges' vertices
+ {
+ let ev = f.i(EDGE_VERTICES);
+ let mut used = vec![false; s.vertices];
+ for &v in ev.iter().chain(f.i(WIRE_EDGE_VERTICES)) {
+ *used.get_mut(v as usize)? = true;
+ }
+ let mut eu = 0usize;
+ for l in 0..s.loops {
+ let n = *lp_count.get(l)? as usize;
+ if n == 0 {
+ *used.get_mut(*lp_vertex.get(l)? as usize)? = true;
+ continue;
+ }
+ for k in 0..n {
+ let (a, b) = (eu + k, eu + (k + 1) % n);
+ let (ea, eb) = (*eu_edge.get(a)? as usize, *eu_edge.get(b)? as usize);
+ let end_a = ev.get(2 * ea + usize::from(*eu_orient.get(a)? == 1))?;
+ let start_b = ev.get(2 * eb + usize::from(*eu_orient.get(b)? != 1))?;
+ if end_a != start_b {
+ return None;
+ }
+ }
+ eu += n;
+ }
+ if !used.iter().all(|&u| u) {
+ return None;
+ }
+ }
+
+ // UV trim curves: none, or one record per edgeuse
+ let (uv_count, uv_order) = (f.i(EDGEUSE_NURBS_VERTEX_COUNT), f.i(EDGEUSE_NURBS_ORDER));
+ if uv_count.len() != uv_order.len() || (!uv_count.is_empty() && uv_count.len() != s.edgeuses) {
+ return None;
+ }
+ s.uv_curves = !uv_count.is_empty();
+ if uv_count.iter().zip(uv_order).any(|(&c, &o)| (c == 0) != (o == 0)) {
+ return None;
+ }
+ if !good_nurbs_curves(uv_count, uv_order, f.r(EDGEUSE_NURBS_CVS), f.r(EDGEUSE_NURBS_KNOTS), f.r(EDGEUSE_NURBS_WEIGHTS), 2, true) {
+ return None;
+ }
+
+ // Surfaces
+ let mut n_t = [0usize; 6];
+ for &t in surface_type {
+ *n_t.get_mut(t as usize)? += 1;
+ }
+ let n_n = (0..s.faces).filter(|&i| face_has_nurbs(f, i)).count();
+ let [_, n_p, n_cy, n_co, n_s, n_to] = n_t;
+ let (cu_v, cv_v, ou_v, ov_v) = (f.i(FACE_NURBS_U_VERTEX_COUNT), f.i(FACE_NURBS_V_VERTEX_COUNT), f.i(FACE_NURBS_U_ORDER), f.i(FACE_NURBS_V_ORDER));
+ if cu_v.len() != n_n || cv_v.len() != n_n || ou_v.len() != n_n || ov_v.len() != n_n {
+ return None;
+ }
+ {
+ let (uks, vks) = (f.r(FACE_NURBS_U_KNOTS), f.r(FACE_NURBS_V_KNOTS));
+ let (mut points, mut uk, mut vk) = (0i64, 0i64, 0i64);
+ for i in 0..n_n {
+ let (cu, cv, ou, ov) = (*cu_v.get(i)?, *cv_v.get(i)?, *ou_v.get(i)?, *ov_v.get(i)?);
+ if !(2..=MAX_ORDER).contains(&ou)
+ || !(2..=MAX_ORDER).contains(&ov)
+ || cu < ou
+ || cv < ov
+ || cu > MAX_FLAT_LEN
+ || cv > MAX_FLAT_LEN
+ || cu * cv > MAX_FLAT_LEN
+ {
+ return None;
+ }
+ points += cu * cv;
+ uk += cu + ou;
+ vk += cv + ov;
+ if points > MAX_FLAT_LEN || uk > MAX_FLAT_LEN || vk > MAX_FLAT_LEN {
+ return None;
+ }
+ }
+ if f.r(FACE_NURBS_CVS).len() as i64 != 3 * points || uks.len() as i64 != uk || vks.len() as i64 != vk {
+ return None;
+ }
+ let w = f.r(FACE_NURBS_WEIGHTS);
+ if !w.is_empty() && (w.len() as i64 != points || !good_weights(w)) {
+ return None;
+ }
+ let (mut uk, mut vk) = (0usize, 0usize);
+ for i in 0..n_n {
+ let (cu, cv, ou, ov) = (*cu_v.get(i)?, *cv_v.get(i)?, *ou_v.get(i)?, *ov_v.get(i)?);
+ if !good_knots(uks.get(uk..)?, cu, ou) || !good_knots(vks.get(vk..)?, cv, ov) {
+ return None;
+ }
+ uk += (cu + ou) as usize;
+ vk += (cv + ov) as usize;
+ }
+ // each NURBS face's range overlaps its surface's knot domain (MakeTopologyFromData
+ // intersects the two)
+ let (mut i_n, mut uk, mut vk) = (0usize, 0usize, 0usize);
+ for i in 0..s.faces {
+ if !face_has_nurbs(f, i) {
+ continue;
+ }
+ let (cu, cv, ou, ov) = (*cu_v.get(i_n)? as usize, *cv_v.get(i_n)? as usize, *ou_v.get(i_n)? as usize, *ov_v.get(i_n)? as usize);
+ let (u_lo, u_hi) = (*uks.get(uk + ou - 1)?, *uks.get(uk + cu)?);
+ let (v_lo, v_hi) = (*vks.get(vk + ov - 1)?, *vks.get(vk + cv)?);
+ if !(range.get(4 * i + 2)?.min(u_hi) > range.get(4 * i)?.max(u_lo))
+ || !(range.get(4 * i + 3)?.min(v_hi) > range.get(4 * i + 1)?.max(v_lo))
+ {
+ return None;
+ }
+ uk += cu + ou;
+ vk += cv + ov;
+ i_n += 1;
+ }
+ }
+ let lens = |id: i32, n: usize| f.r(id).len() == n;
+ if !(lens(FACE_PLANE_ORIGIN, 3 * n_p)
+ && lens(FACE_PLANE_AXIS, 3 * n_p)
+ && lens(FACE_PLANE_REF_DIRECTION, 3 * n_p)
+ && lens(FACE_CYLINDER_ORIGIN, 3 * n_cy)
+ && lens(FACE_CYLINDER_AXIS, 3 * n_cy)
+ && lens(FACE_CYLINDER_REF_DIRECTION, 3 * n_cy)
+ && lens(FACE_CYLINDER_RADIUS, n_cy)
+ && lens(FACE_CONE_ORIGIN, 3 * n_co)
+ && lens(FACE_CONE_AXIS, 3 * n_co)
+ && lens(FACE_CONE_REF_DIRECTION, 3 * n_co)
+ && lens(FACE_CONE_RADIUS, n_co)
+ && lens(FACE_CONE_SEMI_ANGLE, n_co)
+ && lens(FACE_SPHERE_CENTER, 3 * n_s)
+ && lens(FACE_SPHERE_AXIS, 3 * n_s)
+ && lens(FACE_SPHERE_REF_DIRECTION, 3 * n_s)
+ && lens(FACE_SPHERE_RADIUS, n_s)
+ && lens(FACE_TORUS_ORIGIN, 3 * n_to)
+ && lens(FACE_TORUS_AXIS, 3 * n_to)
+ && lens(FACE_TORUS_REF_DIRECTION, 3 * n_to)
+ && lens(FACE_TORUS_MAJOR_RADIUS, n_to)
+ && lens(FACE_TORUS_MINOR_RADIUS, n_to))
+ {
+ return None;
+ }
+ let positive = |id: i32, i: usize| f.r(id).get(i).is_some_and(|&r| r > 0.0);
+ for i in 0..n_p {
+ if !good_frame(f.r(FACE_PLANE_AXIS), f.r(FACE_PLANE_REF_DIRECTION), i) {
+ return None;
+ }
+ }
+ for i in 0..n_cy {
+ if !positive(FACE_CYLINDER_RADIUS, i) || !good_frame(f.r(FACE_CYLINDER_AXIS), f.r(FACE_CYLINDER_REF_DIRECTION), i) {
+ return None;
+ }
+ }
+ for i in 0..n_co {
+ let half = 0.5 * std::f64::consts::PI * (1.0 - 1e-9);
+ if !f.r(FACE_CONE_RADIUS).get(i).is_some_and(|&r| r >= 0.0)
+ || !f.r(FACE_CONE_SEMI_ANGLE).get(i).is_some_and(|a| a.abs() < half)
+ || !good_frame(f.r(FACE_CONE_AXIS), f.r(FACE_CONE_REF_DIRECTION), i)
+ {
+ return None;
+ }
+ }
+ for i in 0..n_s {
+ if !positive(FACE_SPHERE_RADIUS, i) || !good_frame(f.r(FACE_SPHERE_AXIS), f.r(FACE_SPHERE_REF_DIRECTION), i) {
+ return None;
+ }
+ }
+ for i in 0..n_to {
+ if !positive(FACE_TORUS_MAJOR_RADIUS, i)
+ || !positive(FACE_TORUS_MINOR_RADIUS, i)
+ || !good_frame(f.r(FACE_TORUS_AXIS), f.r(FACE_TORUS_REF_DIRECTION), i)
+ {
+ return None;
+ }
+ }
+ Some(s)
+}
+
+// ---- import: FlatToBrepData --------------------------------------------------------------------
+
+/// The running read positions in the packed geometry arrays (`Cursor`).
+#[derive(Default)]
+struct Cursor {
+ nurbs: usize,
+ cv: usize,
+ knot: usize,
+ vknot: usize,
+ circle: usize,
+ line: usize,
+ ellipse: usize,
+}
+
+/// `smu_IsFlatKnotVectorClamped`: the first and last knots each repeat `degree + 1` times.
+fn is_clamped(knots: &[f64], degree: usize) -> bool {
+ let n = knots.len();
+ if n < 2 || degree > (n - 2) / 2 {
+ return false;
+ }
+ let (Some(&first), Some(&last)) = (knots.first(), knots.last()) else { return false };
+ (1..=degree).all(|i| knots.get(i) == Some(&first) && knots.get(n - 1 - i) == Some(&last))
+}
+
+/// `smu_CompressFlatKnots`: the distinct knots and their multiplicities.
+fn compress_knots(knots: &[f64]) -> (Vec, Vec) {
+ let (mut unique, mut mults): (Vec, Vec) = (Vec::new(), Vec::new());
+ for &k in knots {
+ match (unique.last(), mults.last_mut()) {
+ (Some(&u), Some(m)) if u == k => *m += 1,
+ _ => {
+ unique.push(k);
+ mults.push(1);
+ }
+ }
+ }
+ (unique, mults)
+}
+
+/// `smu_CreateSmBSplineCurve_FromArrays`: a curve of `dim` 2 or 3; an unclamped knot vector goes
+/// through `SmBSplineCurve::CreateCanonical`, which clamps it.
+fn bspline_curve_from_arrays(points: &[Point3], weights: &[f64], knots: &[f64], degree: usize, dim: u64) -> Result {
+ if !is_clamped(knots, degree) {
+ let (unique, mults) = compress_knots(knots);
+ let data = CanonicalData {
+ dim,
+ degree,
+ control_points: points,
+ form: BSplineCurveForm::Unspecified,
+ knot_multiplicities: &mults,
+ knots: &unique,
+ knot_type: KnotType::Unspecified,
+ weights: Some(weights),
+ trim: None,
+ };
+ return BSplineCurve::create_canonical(&data).map_err(sc("SmBSplineCurve::CreateCanonical"));
+ }
+ let mut pw = Vec::with_capacity(points.len());
+ for (p, &w) in points.iter().zip(weights) {
+ let z = if dim == 3 { if w == 1.0 { p.z } else { w * p.z } } else { NOZ };
+ pw.push(if w == 1.0 { CPoint::new(p.x, p.y, z, 1.0) } else { CPoint::new(w * p.x, w * p.y, z, w) });
+ }
+ let nurb = NurbCurve::new(pw, degree, knots.to_vec()).map_err(sc("sm_AllocateNurbCurve"))?;
+ let mut c = BSplineCurve::new(dim);
+ c.set_from_gw_nurb(Some(&nurb)).map_err(sc("SmBSplineCurve::SetFromGwNurb"))?;
+ Ok(c)
+}
+
+/// `smu_CreateSmBSplineSurface_FromArrays`: control points U-major; an unclamped knot vector goes
+/// through `SmBSplineSurface::CreateCanonical`.
+fn bspline_surface_from_arrays(
+ points: &[Point3],
+ weights: &[f64],
+ u_knots: &[f64],
+ v_knots: &[f64],
+ u_degree: usize,
+ v_degree: usize,
+) -> Result {
+ if !is_clamped(u_knots, u_degree) || !is_clamped(v_knots, v_degree) {
+ let (uu, um) = compress_knots(u_knots);
+ let (vu, vm) = compress_knots(v_knots);
+ let data = CanonicalSurfaceData {
+ u_degree,
+ v_degree,
+ control_points: points,
+ form: BSplineSurfaceForm::Unspecified,
+ u_multiplicities: &um,
+ v_multiplicities: &vm,
+ u_knots: &uu,
+ v_knots: &vu,
+ knot_type: KnotType::Unspecified,
+ weights: Some(weights),
+ uv_domain: None,
+ };
+ return BSplineSurface::create_canonical(&data).map_err(sc("SmBSplineSurface::CreateCanonical"));
+ }
+ let nu = u_knots.len().checked_sub(u_degree + 1).ok_or_else(|| bad("u knots"))?;
+ let nv = v_knots.len().checked_sub(v_degree + 1).ok_or_else(|| bad("v knots"))?;
+ let mut net = 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 (p, w) = (get(points, at)?, get(weights, at)?);
+ row.push(CPoint::new(w * p.x, w * p.y, w * p.z, w));
+ }
+ net.push(row);
+ }
+ let nurb = NurbSurface::new(net, u_degree, v_degree, u_knots.to_vec(), v_knots.to_vec()).map_err(sc("sm_AllocateNurbSurface"))?;
+ Ok(BSplineSurface::from_nurb(nurb, false))
+}
+
+/// The packed control points `[c, c + n)` of `dim` coordinates (z 0 for 2D) and their weights,
+/// all 1 without a weight array.
+fn packed_points(cvs: &[f64], weights: &[f64], c: usize, n: usize, dim: usize, reversed: bool) -> Result<(Vec, Vec)> {
+ let weighted = weights.len() * dim == cvs.len();
+ let (mut pts, mut ws) = (Vec::with_capacity(n), Vec::with_capacity(n));
+ for j in 0..n {
+ let src = c + if reversed { n - 1 - j } else { j };
+ let p = if dim == 3 { get3(cvs, src)? } else { Vec3::new(get(cvs, 2 * src)?, get(cvs, 2 * src + 1)?, 0.0) };
+ pts.push(p);
+ ws.push(if weighted { get(weights, src)? } else { 1.0 });
+ }
+ Ok((pts, ws))
+}
+
+/// `smu_CreateSmBSplineCurve3d`: the next NURBS curve of an edge family.
+fn bspline_curve_3d(e: &EdgeIn<'_>, c: &mut Cursor) -> Result {
+ let degree = idx(get(e.order, c.nurbs)? - 1)?;
+ let n = idx(get(e.count, c.nurbs)?)?;
+ let n_knots = n + degree + 1;
+ let (points, weights) = packed_points(e.cvs, e.weights, c.cv, n, 3, false)?;
+ let knots = e.knots.get(c.knot..c.knot + n_knots).ok_or_else(|| bad("edge knots"))?;
+ let curve = bspline_curve_from_arrays(&points, &weights, knots, degree, 3);
+ c.nurbs += 1;
+ c.cv += n;
+ c.knot += n_knots;
+ curve
+}
+
+/// `smu_CreateSmBSplineCurve2d_ForEdgeuseIndex`: edgeuse `k`'s UV trim, reversed onto its edge's
+/// direction and its knots mapped affinely onto the edge's range (in SMLib units); `None` when it
+/// has none, or its face no NURBS block (an analytic face's trims are rebuilt from the edges).
+fn bspline_curve_2d(f: &In<'_>, c: &mut Cursor, k: usize, face_has_nurbs: bool, same: bool) -> Result> {
+ let (order, n) = (get(f.i(EDGEUSE_NURBS_ORDER), k)?, get(f.i(EDGEUSE_NURBS_VERTEX_COUNT), k)?);
+ if order == 0 || n == 0 {
+ return Ok(None);
+ }
+ let degree = idx(order - 1)?;
+ let n = idx(n)?;
+ let n_knots = n + degree + 1;
+ let (cv, knot) = (c.cv, c.knot);
+ c.cv += n;
+ c.knot += n_knots;
+ if !face_has_nurbs {
+ return Ok(None);
+ }
+ let (points, weights) = packed_points(f.r(EDGEUSE_NURBS_CVS), f.r(EDGEUSE_NURBS_WEIGHTS), cv, n, 2, !same)?;
+ // SMLib requires every UV trim to run over its edge's interval: map the knots affinely onto
+ // the edge's range (degrees for an analytic circle or ellipse)
+ let e = idx(get(f.i(EDGEUSE_EDGE), k)?)?;
+ let (mut lo, mut hi) = (get(f.r(EDGE_RANGE), 2 * e)?, get(f.r(EDGE_RANGE), 2 * e + 1)?);
+ let ty = get(f.i(EDGE_CURVE_TYPE), e)?;
+ if (ty == CURVE_CIRCLE_I || ty == CURVE_ELLIPSE_I) && !has_form(f.i(EDGE_NURBS_FORM), e) {
+ lo = radians_to_smlib_degrees(lo);
+ hi = radians_to_smlib_degrees(hi);
+ }
+ let src = f.r(EDGEUSE_NURBS_KNOTS).get(knot..knot + n_knots).ok_or_else(|| bad("trim knots"))?;
+ let (s_lo, s_hi) = (get(src, degree)?, get(src, n)?);
+ let scale = (hi - lo) / (s_hi - s_lo);
+ let mut knots = Vec::with_capacity(n_knots);
+ for j in 0..n_knots {
+ let s = if same { get(src, j)? } else { s_lo + s_hi - get(src, n_knots - 1 - j)? };
+ knots.push(lo + (s - s_lo) * scale);
+ }
+ bspline_curve_from_arrays(&points, &weights, &knots, degree, 2).map(Some)
+}
+
+/// `smu_CreateSmBSplineSurface3d`: the next NURBS face's surface.
+fn bspline_surface_3d(f: &In<'_>, c: &mut Cursor) -> Result {
+ let ud = idx(get(f.i(FACE_NURBS_U_ORDER), c.nurbs)? - 1)?;
+ let vd = idx(get(f.i(FACE_NURBS_V_ORDER), c.nurbs)? - 1)?;
+ let nu = idx(get(f.i(FACE_NURBS_U_VERTEX_COUNT), c.nurbs)?)?;
+ let nv = idx(get(f.i(FACE_NURBS_V_VERTEX_COUNT), c.nurbs)?)?;
+ let (nuk, nvk, n) = (nu + ud + 1, nv + vd + 1, nu * nv);
+ let (points, weights) = packed_points(f.r(FACE_NURBS_CVS), f.r(FACE_NURBS_WEIGHTS), c.cv, n, 3, false)?;
+ let uk = f.r(FACE_NURBS_U_KNOTS).get(c.knot..c.knot + nuk).ok_or_else(|| bad("u knots"))?;
+ let vk = f.r(FACE_NURBS_V_KNOTS).get(c.vknot..c.vknot + nvk).ok_or_else(|| bad("v knots"))?;
+ let s = bspline_surface_from_arrays(&points, &weights, uk, vk, ud, vd);
+ c.nurbs += 1;
+ c.cv += n;
+ c.knot += nuk;
+ c.vknot += nvk;
+ s
+}
+
+/// `SameNurbs`: equal sizes and orders, knots and weights within rounding, control points within
+/// `tol`.
+fn same_nurbs(a: &NurbSurface, b: &NurbSurface, tol: f64) -> bool {
+ let (mut fa, mut fb) = (Out::new(), Out::new());
+ if add_bspline_surface(&mut fa, a).is_err() || add_bspline_surface(&mut fb, b).is_err() {
+ return false;
+ }
+ for id in [FACE_NURBS_U_VERTEX_COUNT, FACE_NURBS_V_VERTEX_COUNT, FACE_NURBS_U_ORDER, FACE_NURBS_V_ORDER] {
+ if fa.i(id) != fb.i(id) {
+ return false;
+ }
+ }
+ let close = |x: &[f64], y: &[f64], t: f64| x.len() == y.len() && x.iter().zip(y).all(|(a, b)| (a - b).abs() <= t * a.abs().max(1.0));
+ close(fa.r(FACE_NURBS_U_KNOTS), fb.r(FACE_NURBS_U_KNOTS), 1e-12)
+ && close(fa.r(FACE_NURBS_V_KNOTS), fb.r(FACE_NURBS_V_KNOTS), 1e-12)
+ && close(fa.r(FACE_NURBS_WEIGHTS), fb.r(FACE_NURBS_WEIGHTS), 1e-12)
+ && close(fa.r(FACE_NURBS_CVS), fb.r(FACE_NURBS_CVS), tol)
+}
+
+/// `AnalyticOfNurbs`: the analytic surface of kind `ty` SMLib's own test finds in `nurbs`, kept
+/// only when its NURBS is `nurbs` (the face's trims are in its parameters).
+fn analytic_of_nurbs(k: &Kernel, nurbs: &BSplineSurface, ty: i64, tol: f64) -> Result> {
+ let found: Option = if ty == SURFACE_CONE_I {
+ answer(Cone::is_nurb_surface_cone(k, nurbs, 1.0)).map_err(sc("SmCone::IsNurbSurfaceCone"))?.flatten().map(Surface::from)
+ } else if ty == SURFACE_SPHERE_I {
+ answer(Sphere::is_nurb_surface_sphere(k, nurbs, 1.0)).map_err(sc("SmSphere::IsNurbSurfaceSphere"))?.flatten().map(Surface::from)
+ } else if ty == SURFACE_CYLINDER_I {
+ answer(Cone::is_nurb_surface_cylinder(k, nurbs, 1.0)).map_err(sc("SmCylinder::IsNurbSurfaceCylinder"))?.flatten().map(Surface::from)
+ } else if ty == SURFACE_TORUS_I {
+ answer(Torus::is_nurb_surface_torus(k, nurbs, 1.0)).map_err(sc("SmTorus::IsNurbSurfaceTorus"))?.flatten().map(Surface::from)
+ } else {
+ None
+ };
+ let Some(found) = found else { return Ok(None) };
+ let (Some(want), Some(part)) = (nurbs.gw_nurb(), bspline_surface_part(k, &found)?) else { return Ok(None) };
+ Ok(part.gw_nurb().is_some_and(|got| same_nurbs(want, got, tol)).then_some(found))
+}
+
+/// `Placement`: X the reference direction, Y the axis cross it (`SetCanonical`).
+fn placement(origin: Point3, z: Vec3, x: Vec3) -> Result {
+ let mut p = Axis2Placement::default();
+ p.set_canonical(origin, x, z.cross(x)).map_err(|_| bad("a frame that spans no plane"))?;
+ Ok(p)
+}
+
+/// `SameNurbsCurve`: equal counts and orders, knots and weights within rounding, control points
+/// within rounding of the curve's size (`smu_AddSmBSplineCurve3d` of each).
+fn same_nurbs_curve(a: &NurbCurve, b: &NurbCurve) -> bool {
+ let arrays = |n: &NurbCurve| {
+ let (mut count, mut order, mut cvs, mut knots, mut weights) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
+ let o = NurbsOut { count: &mut count, order: &mut order, cvs: &mut cvs, knots: &mut knots, weights: &mut weights };
+ add_bspline_curve_3d(n, o).ok().map(|()| (count, order, cvs, knots, weights))
+ };
+ let (Some((ac, ao, ap, ak, aw)), Some((bc, bo, bp, bk, bw))) = (arrays(a), arrays(b)) else { return false };
+ if ac != bc || ao != bo || ap.len() != bp.len() || ak.len() != bk.len() || aw.len() != bw.len() {
+ return false;
+ }
+ let size = ap.iter().fold(1.0_f64, |m, x| m.max(x.abs()));
+ let near = |x: &[f64], y: &[f64]| x.iter().zip(y).all(|(a, b)| (a - b).abs() <= 1e-12 * a.abs().max(1.0));
+ ap.iter().zip(&bp).all(|(a, b)| (a - b).abs() <= 1e-12 * size) && near(&ak, &bk) && near(&aw, &bw)
+}
+
+/// `MakeEdgeCurve` (capi/src/smlib_flat.cpp): one edge's 3D curve, as its kind says;
+/// `d_min`/`d_max` its range in SMLib units. A circle or ellipse with its NURBS form (`form`) is that NURBS as the conic of its
+/// canonical entry (frame and radii; the domain from `ConicDomainOfNurbs`), keeping that NURBS.
+/// A circle's NURBS must pass SMLib's own test (`SmCircle::IsNurbCurveCircle`), whose circle is
+/// not the one kept (its frame has its x axis towards the NURBS's start), and the kept circle
+/// must hold the same NURBS (`SameNurbsCurve`); either failing is `SM_ERR_INVALID_INPUT`.
+fn make_edge_curve(k: &Kernel, ty: i64, d_min: f64, d_max: f64, e: &EdgeIn<'_>, c: &mut Cursor, form: bool) -> Result {
+ if form {
+ let circle = ty == CURVE_CIRCLE_I;
+ let i = if circle {
+ c.circle += 1;
+ c.circle - 1
+ } else {
+ c.ellipse += 1;
+ c.ellipse - 1
+ };
+ let nurbs = bspline_curve_3d(e, c)?;
+ let (o, mut z, mut x, rx, ry) = if circle {
+ let r = get(e.circle_radius, i)?;
+ (get3(e.circle_center, i)?, get3(e.circle_axis, i)?, get3(e.circle_ref, i)?, r, r)
+ } else {
+ (get3(e.ellipse_center, i)?, get3(e.ellipse_axis, i)?, get3(e.ellipse_ref, i)?, get(e.ellipse_x, i)?, get(e.ellipse_y, i)?)
+ };
+ if circle {
+ let test = Curve::from(nurbs.clone_exact());
+ let is_circle = answer(Ellipse::is_nurb_curve_circle(k, &test, None)).map_err(sc("SmCircle::IsNurbCurveCircle"))?.flatten();
+ if is_circle.is_none() {
+ return Err(bad("a circle NURBS SMLib's circle test rejects"));
+ }
+ }
+ let domain = conic_domain_of_nurbs(&nurbs, o, &mut z, &mut x, rx, ry).ok_or_else(|| bad("a conic NURBS off its conic"))?;
+ let opts = EllipseOptions { dim: 3, nurb: Some(&nurbs), inside_out: false };
+ if circle {
+ let cir = Ellipse::new_circle(k, o, x, z.cross(x), domain, rx, &opts).map_err(sc("SmCircle::SmCircle"))?;
+ let kept = nurb_of(cir.base())?;
+ if !nurbs.gw_nurb().is_some_and(|want| same_nurbs_curve(want, kept)) {
+ return Err(bad("a circle that does not keep its NURBS"));
+ }
+ return Ok(Curve::from(cir));
+ }
+ let el = Ellipse::new(k, o, x, z.cross(x), domain, rx, ry, &opts).map_err(sc("SmEllipse::SmEllipse"))?;
+ return Ok(Curve::from(el));
+ }
+ if ty == CURVE_NURBS_I {
+ return bspline_curve_3d(e, c).map(Curve::from);
+ }
+ if ty == CURVE_CIRCLE_I {
+ let i = c.circle;
+ c.circle += 1;
+ let r = get(e.circle_radius, i)?;
+ return build_analytic_edge_curve(k, ty, d_min, d_max, get3(e.circle_center, i)?, get3(e.circle_axis, i)?, get3(e.circle_ref, i)?, r, r);
+ }
+ if ty == CURVE_LINE_I {
+ let i = c.line;
+ c.line += 1;
+ let zero = Vec3::new(0.0, 0.0, 0.0);
+ return build_analytic_edge_curve(k, ty, d_min, d_max, get3(e.line_origin, i)?, get3(e.line_direction, i)?, zero, 0.0, 0.0);
+ }
+ let i = c.ellipse;
+ c.ellipse += 1;
+ build_analytic_edge_curve(
+ k,
+ ty,
+ d_min,
+ d_max,
+ get3(e.ellipse_center, i)?,
+ get3(e.ellipse_axis, i)?,
+ get3(e.ellipse_ref, i)?,
+ get(e.ellipse_x, i)?,
+ get(e.ellipse_y, i)?,
+ )
+}
+
+/// `ConicDomainOfNurbs`: the domain in degrees of the conic (centre `o`, axis `z`, reference `x`,
+/// radii `rx`, `ry`) the NURBS traces, `z` and `x` made an orthonormal frame; `None` when a point
+/// is off that conic or the NURBS turns clockwise about `z`.
+fn conic_domain_of_nurbs(nurbs: &BSplineCurve, o: Point3, z: &mut Vec3, x: &mut Vec3, rx: f64, ry: f64) -> Option {
+ *z = z.unitize().ok()?;
+ *x = (*x - *z * x.dot(*z)).unitize().ok()?;
+ if !(rx > 0.0 && ry > 0.0) {
+ return None;
+ }
+ let y = z.cross(*x);
+ let ivl = nurbs.natural_interval();
+ let angle = |t: f64| -> Option {
+ let p = nurbs.evaluate_point(t).ok()?;
+ let d = p - o;
+ let (u, v) = (d.dot(*x) / rx, d.dot(y) / ry);
+ let on = ((u.hypot(v)) - 1.0).abs() <= 1e-9 && d.dot(*z).abs() <= 1e-9 * (1.0 + rx + ry);
+ on.then(|| v.atan2(u) * 180.0 / std::f64::consts::PI)
+ };
+ let n = 16;
+ let (mut a0, mut prev) = (0.0, 0.0);
+ for step in 0..=n {
+ let a = angle(ivl.min() + (ivl.max() - ivl.min()) * f64::from(step) / f64::from(n))?;
+ if step == 0 {
+ a0 = a;
+ } else if !(ieee_remainder(a - prev, 360.0) > 0.0) {
+ return None;
+ }
+ prev = a;
+ }
+ let mut sweep = (prev - a0 + 720.0) % 360.0;
+ if sweep <= 1e-9 {
+ sweep = 360.0;
+ }
+ if a0 + sweep > 360.0 { Some(Extent1d::from_raw(a0 - 360.0, a0 - 360.0 + sweep)) } else { Some(Extent1d::from_raw(a0, a0 + sweep)) }
+}
+
+/// `std::remainder`: `x - n * y` with `n` the integer nearest `x / y`, ties to even.
+fn ieee_remainder(x: f64, y: f64) -> f64 {
+ let q = x / y;
+ let mut n = q.round();
+ if (q - q.trunc()).abs() == 0.5 && n % 2.0 != 0.0 {
+ n -= q.signum();
+ }
+ x - n * y
+}
+
+/// `FlatToBrepData`: the validated arrays as `SmBrepData`. Edges are `[face edges, wire edges]`,
+/// vertices `[point-shell vertices, vertices]`, edgeuses `[wire edgeuses, face edgeuses]`.
+fn flat_to_brep_data(k: &Kernel, f: &In<'_>, s: &Shape) -> Result {
+ let xsect = get(f.r(TOLERANCE), 0)?;
+ let zone = xsect / 2.0;
+ let mut d = BrepData::new(UNSURE, DataSourceType::default());
+ d.data_source_type = DataSourceType::Usd;
+ d.zone_tol_3d = zone;
+ d.approx_tol_3d = xsect / 4.0;
+ d.start_region = 0;
+ d.num_regions = s.regions as u64;
+ d.color = Vec3::new(0.0, 0.0, 0.0);
+ d.is_geom_borrowed = false;
+ let mut next_key = 1u64;
+ let mut key = || {
+ next_key += 1;
+ ObjectKey(next_key - 1)
+ };
+ let (n_edges, n_wire, n_point) = (s.edges as u64, s.wire_edges as u64, s.point_shells as u64);
+ let undefined = |t: &mut solidcore::topology::brep_data::TopologyData| {
+ t.flags = 0;
+ t.user_index1 = 0;
+ t.user_index2 = 0;
+ };
+
+ // Regions
+ let mut start_shell = 0u64;
+ for i in 0..s.regions {
+ let mut r = RegionData::default();
+ r.start_shell = start_shell;
+ r.num_shells = get(f.i(REGION_SHELL_COUNT), i)? as u64;
+ r.is_void = i32::from(get(f.i(REGION_TYPE), i)? == 1);
+ undefined(&mut r.topology);
+ start_shell += r.num_shells;
+ d.regions.push(r);
+ }
+
+ // Shells, the wire edges' edgeuses (first in the edgeuse list) and point-shell vertices
+ let mut faceuse_start = 0u64;
+ let mut wire = 0u64;
+ let mut point = 0usize;
+ for i in 0..s.shells {
+ let mut sd = ShellData::default();
+ let (n_fu, n_we) = (get(f.i(SHELL_FACEUSE_COUNT), i)? as u64, get(f.i(SHELL_WIRE_EDGE_COUNT), i)? as u64);
+ sd.shell_type = if n_fu > 0 {
+ 0
+ } else if n_we > 0 {
+ 1
+ } else {
+ 2
+ };
+ sd.num_faceuses = 0;
+ undefined(&mut sd.topology);
+ if n_fu > 0 {
+ sd.faceuse_start = faceuse_start;
+ sd.num_faceuses = n_fu;
+ faceuse_start += n_fu;
+ }
+ if n_we > 0 {
+ sd.edge = wire + n_edges;
+ for _ in 0..n_we {
+ let mut eu = EuData::default();
+ eu.eu_type = 2;
+ eu.edge = n_edges + wire;
+ eu.shell = i as u64;
+ eu.orientation = 1;
+ undefined(&mut eu.topology);
+ eu.mate_flags = 0;
+ eu.mate_user_index1 = 0;
+ eu.mate_user_index2 = 0;
+ eu.uv_curve = NO_OBJECT;
+ d.edgeuses.push(eu);
+ wire += 1;
+ }
+ }
+ if sd.shell_type == 2 {
+ sd.vertex = point as u64;
+ let mut v = VertexData::default();
+ v.zone_tol_3d = zone;
+ undefined(&mut v.topology);
+ v.point = get3(f.r(SHELL_POINT_POSITION), point)?;
+ d.vertices.push(v);
+ point += 1;
+ }
+ d.shells.push(sd);
+ }
+
+ // Faceuses
+ for i in 0..s.faceuses {
+ let mut fu = FaceuseData::default();
+ fu.face = get(f.i(FACEUSE_FACE), i)? as u64;
+ fu.orientation = i32::from(get(f.i(FACEUSE_ORIENTATION), i)? == 1);
+ undefined(&mut fu.topology);
+ d.faceuses.push(fu);
+ }
+
+ // Faces and their surfaces
+ let mut start_loop = 0u64;
+ let mut nurbs_cursor = Cursor::default();
+ let (mut i_sphere, mut i_plane, mut i_cyl, mut i_cone, mut i_torus) = (0usize, 0usize, 0usize, 0usize, 0usize);
+ for i in 0..s.faces {
+ let ty = get(f.i(FACE_SURFACE_TYPE), i)?;
+ let form = ty != SURFACE_NURBS_I && face_has_nurbs(f, i);
+ // "rectangular" -> TRUE; "general" -> UNSURE (not determined)
+ let rectangular = if get(f.i(FACE_TRIM_TYPE), i)? == 1 { 1 } else { UNSURE };
+ let r = f.r(FACE_RANGE);
+ let (mut u0, mut v0, mut u1, mut v1) = (get(r, 4 * i)?, get(r, 4 * i + 1)?, get(r, 4 * i + 2)?, get(r, 4 * i + 3)?);
+ // analytic angles: radians to SMLib's degrees (a NURBS form's range is its own)
+ if !form {
+ if ty == SURFACE_SPHERE_I || ty == SURFACE_TORUS_I {
+ (u0, v0, u1, v1) =
+ (radians_to_smlib_degrees(u0), radians_to_smlib_degrees(v0), radians_to_smlib_degrees(u1), radians_to_smlib_degrees(v1));
+ } else if ty == SURFACE_CYLINDER_I || ty == SURFACE_CONE_I {
+ (u0, u1) = (radians_to_smlib_degrees(u0), radians_to_smlib_degrees(u1));
+ }
+ }
+ let mut domain = extent2d(u0, v0, u1, v1)?;
+ let mut fd = FaceData::default();
+ fd.zone_tol_3d = zone;
+ fd.surface = d.surfaces.len() as u64;
+ fd.start_loop = start_loop;
+ fd.num_loops = get(f.i(FACE_LOOP_COUNT), i)? as u64;
+ fd.uv_domain = domain;
+ fd.rectangular_trim = rectangular;
+ undefined(&mut fd.topology);
+
+ let mut surface: Surface = if form {
+ // an analytic face with its NURBS form: that NURBS as the class the arrays name
+ i_sphere += usize::from(ty == SURFACE_SPHERE_I);
+ i_cyl += usize::from(ty == SURFACE_CYLINDER_I);
+ i_cone += usize::from(ty == SURFACE_CONE_I);
+ i_torus += usize::from(ty == SURFACE_TORUS_I);
+ let nurbs = bspline_surface_3d(f, &mut nurbs_cursor)?;
+ analytic_of_nurbs(k, &nurbs, ty, xsect)?.ok_or_else(|| bad("a NURBS form that is not its analytic surface"))?
+ } else if ty == SURFACE_SPHERE_I {
+ let j = i_sphere;
+ i_sphere += 1;
+ let p = placement(get3(f.r(FACE_SPHERE_CENTER), j)?, get3(f.r(FACE_SPHERE_AXIS), j)?, get3(f.r(FACE_SPHERE_REF_DIRECTION), j)?)?;
+ Surface::from(Sphere::create_canonical(k, &p, get(f.r(FACE_SPHERE_RADIUS), j)?).map_err(sc("SmSphere::CreateCanonical"))?)
+ } else if ty == SURFACE_PLANE_I {
+ let j = i_plane;
+ i_plane += 1;
+ let p = placement(get3(f.r(FACE_PLANE_ORIGIN), j)?, get3(f.r(FACE_PLANE_AXIS), j)?, get3(f.r(FACE_PLANE_REF_DIRECTION), j)?)?;
+ // its domain is restricted below without changing its parameterisation
+ Surface::from(Plane::create_canonical(&p).map_err(sc("SmPlane::CreateCanonical"))?)
+ } else if ty == SURFACE_CYLINDER_I {
+ let j = i_cyl;
+ i_cyl += 1;
+ let p = placement(get3(f.r(FACE_CYLINDER_ORIGIN), j)?, get3(f.r(FACE_CYLINDER_AXIS), j)?, get3(f.r(FACE_CYLINDER_REF_DIRECTION), j)?)?;
+ Surface::from(Cone::create_canonical_cylinder(k, &p, get(f.r(FACE_CYLINDER_RADIUS), j)?).map_err(sc("SmCylinder::CreateCanonical"))?)
+ } else if ty == SURFACE_CONE_I {
+ let j = i_cone;
+ i_cone += 1;
+ let mut origin = get3(f.r(FACE_CONE_ORIGIN), j)?;
+ let mut z = get3(f.r(FACE_CONE_AXIS), j)?;
+ let x = get3(f.r(FACE_CONE_REF_DIRECTION), j)?;
+ let radius = get(f.r(FACE_CONE_RADIUS), j)?;
+ let mut semi = rad2deg(get(f.r(FACE_CONE_SEMI_ANGLE), j)?);
+ // a negative semi-angle is the same cone with its axis and both parameters reversed
+ if semi < 0.0 {
+ semi = -semi;
+ z = Vec3::new(-z.x, -z.y, -z.z);
+ domain = extent2d(-domain.u_max(), -domain.v_max(), -domain.u_min(), -domain.v_min())?;
+ }
+ let mut cone = None;
+ if domain.is_bounded() {
+ // the finite segment, built at its bottom and its axial parameter then [0, height]
+ let (vmin, vmax) = (domain.v_min(), domain.v_max());
+ let height = vmax - vmin;
+ let tan = deg2rad(semi).tan();
+ let (bottom, top) = (radius + vmin * tan, radius + vmax * tan);
+ if height > EFF_ZERO && bottom >= -EFF_ZERO && top >= -EFF_ZERO {
+ origin += z * vmin;
+ let p = placement(origin, z, x)?;
+ cone = Some(
+ Cone::create_canonical(k, &p, bottom.max(0.0), top.max(0.0), height, false, false).map_err(sc("SmCone::CreateCanonical")),
+ );
+ domain = extent2d(domain.u_min(), 0.0, domain.u_max(), height)?;
+ }
+ } else {
+ let p = placement(origin, z, x)?;
+ cone = Some(Cone::create_canonical_with_semi_angle(k, &p, radius, semi, None, false, false).map_err(sc("SmCone::CreateCanonical")));
+ }
+ Surface::from(cone.ok_or_else(|| bad("a cone segment through its apex"))??)
+ } else if ty == SURFACE_TORUS_I {
+ let j = i_torus;
+ i_torus += 1;
+ let p = placement(get3(f.r(FACE_TORUS_ORIGIN), j)?, get3(f.r(FACE_TORUS_AXIS), j)?, get3(f.r(FACE_TORUS_REF_DIRECTION), j)?)?;
+ let (major, minor) = (get(f.r(FACE_TORUS_MAJOR_RADIUS), j)?, get(f.r(FACE_TORUS_MINOR_RADIUS), j)?);
+ Surface::from(Torus::create_canonical(k, &p, major, minor, TorusOptions::default(), None).map_err(sc("SmTorus::CreateCanonical"))?)
+ } else {
+ Surface::from(bspline_surface_3d(f, &mut nurbs_cursor)?)
+ };
+
+ // An analytic surface over its STEP range, and the face's domain its NURBS image (the
+ // natural domain the topology build intersects with).
+ if ty != SURFACE_NURBS_I && !form && domain.is_bounded() {
+ surface.adjust_step_uv_domain(k, &domain).map_err(sc("AdjustSTEPUVDomain"))?;
+ let nurbs_domain = surface.convert_domain_from_step_to_nurbs(k, &domain).map_err(sc("ConvertDomainFromSTEPToNURBS"))?;
+ fd.uv_domain = extent2d(nurbs_domain.u_min(), nurbs_domain.v_min(), nurbs_domain.u_max(), nurbs_domain.v_max())?;
+ }
+ d.surfaces.push(Keyed { key: key(), object: surface });
+ start_loop += fd.num_loops;
+ d.faces.push(fd);
+ }
+
+ // Loops; each edgeuse's loop and face follow from the loop counts
+ let mut eu_loop = vec![0u64; s.edgeuses];
+ let mut eu_face = vec![0usize; s.edgeuses];
+ // (face, vertex) of each vertex loop
+ let mut loop_vertices = Vec::new();
+ {
+ let mut next_eu = n_wire;
+ let (mut eu, mut face) = (0usize, 0usize);
+ let mut face_loop_end = if s.faces > 0 { get(f.i(FACE_LOOP_COUNT), 0)? as usize } else { 0 };
+ for l in 0..s.loops {
+ while l >= face_loop_end && face + 1 < s.faces {
+ face += 1;
+ face_loop_end += get(f.i(FACE_LOOP_COUNT), face)? as usize;
+ }
+ let n_eu = get(f.i(LOOP_EDGEUSE_COUNT), l)? as u64;
+ let mut ld = LoopData::default();
+ ld.loop_type = if n_eu > 0 { 0 } else { 1 };
+ ld.num_eu = n_eu;
+ undefined(&mut ld.topology);
+ if n_eu > 0 {
+ ld.start_eu = next_eu;
+ next_eu += n_eu;
+ for _ in 0..n_eu {
+ *eu_loop.get_mut(eu).ok_or_else(|| bad("edgeuse count"))? = l as u64;
+ *eu_face.get_mut(eu).ok_or_else(|| bad("edgeuse count"))? = face;
+ eu += 1;
+ }
+ } else {
+ // no SmEUData for a loop vertex
+ ld.vertex = n_point + get(f.i(LOOP_VERTEX), l)? as u64;
+ loop_vertices.push((face, ld.vertex));
+ }
+ d.loops.push(ld);
+ }
+ }
+
+ // Edgeuses (face edges; the wire edges' came with their shells)
+ let mut prim_eu = vec![NO_OBJECT as i64; s.edges];
+ let mut uv_cursor = Cursor::default();
+ let (eu_next, eu_entry) = (f.i(EDGEUSE_NEXT_RADIAL), f.i(EDGEUSE_RADIAL_ENTRY));
+ for kk in 0..s.edgeuses {
+ let here = (n_wire + kk as u64) as i64;
+ let next = idx(get(eu_next, kk)?)?;
+ let next_index = (n_wire as i64) + next as i64;
+ let this_top = get(eu_entry, kk)? == 1;
+ let next_top = get(eu_entry, next)? == 1;
+ let next_flag = if next_top { next_index } else { -next_index - 1 };
+ let same = get(f.i(EDGEUSE_ORIENTATION), kk)? == 1;
+ let mut eu = EuData::default();
+ eu.eu_type = 0;
+ eu.edge = get(f.i(EDGEUSE_EDGE), kk)? as u64;
+ eu.loop_ = get(&eu_loop, kk)?;
+ eu.orientation = i32::from(same);
+ eu.downward_lu = false;
+ undefined(&mut eu.topology);
+ // topEntry: top -> bottom -> next; bottomEntry: bottom -> top -> next
+ if this_top {
+ eu.next_eu = -here - 1;
+ eu.mate_next_eu = next_flag;
+ } else {
+ eu.next_eu = next_flag;
+ eu.mate_next_eu = here;
+ }
+ eu.uv_curve = NO_OBJECT;
+ if s.uv_curves
+ && let Some(trim) = bspline_curve_2d(f, &mut uv_cursor, kk, face_has_nurbs(f, get(&eu_face, kk)?), same)?
+ {
+ eu.uv_curve = d.uv_curves.len() as u64;
+ d.uv_curves.push(Keyed { key: key(), object: Curve::from(trim) });
+ }
+ // the primary edgeuse is the first on the radial list (its mate the second)
+ let slot = prim_eu.get_mut(index_u64(eu.edge)?).ok_or_else(|| bad("edgeuse edge"))?;
+ if *slot == NO_OBJECT as i64 {
+ *slot = if this_top { here } else { -here - 1 };
+ }
+ d.edgeuses.push(eu);
+ }
+
+ // Edges, then wire edges
+ for (family, n, offset) in [(&EDGES, s.edges, 0u64), (&WIRE_EDGES, s.wire_edges, n_edges)] {
+ let e = EdgeIn::new(f, family);
+ let tolerances = if offset == 0 { f.r(EDGE_TOLERANCE) } else { &[] };
+ let mut c = Cursor::default();
+ for i in 0..n {
+ let mut ed = EdgeData::default();
+ ed.zone_tol_3d = if tolerances.is_empty() { zone } else { get(tolerances, i)? / 2.0 };
+ let ty = get(e.ty, i)?;
+ let form = has_form(e.form, i);
+ let (mut lo, mut hi) = (get(e.range, 2 * i)?, get(e.range, 2 * i + 1)?);
+ // circle and ellipse ranges are radians; SMLib's circles and ellipses take degrees
+ if !form && (ty == CURVE_CIRCLE_I || ty == CURVE_ELLIPSE_I) {
+ (lo, hi) = (radians_to_smlib_degrees(lo), radians_to_smlib_degrees(hi));
+ }
+ if !(lo <= hi) {
+ return Err(bad("edge range"));
+ }
+ ed.interval = Extent1d::from_raw(lo, hi);
+ ed.curve = d.curves_3d.len() as u64;
+ ed.start_vertex = n_point + get(e.vertices, 2 * i)? as u64;
+ ed.end_vertex = n_point + get(e.vertices, 2 * i + 1)? as u64;
+ // a wire edge's edgeuse index is its index among the wire edges
+ ed.prim_eu = if offset == 0 { get(&prim_eu, i)? } else { i as i64 };
+ undefined(&mut ed.topology);
+ let curve = make_edge_curve(k, ty, lo, hi, &e, &mut c, form)?;
+ d.curves_3d.push(Keyed { key: key(), object: curve });
+ d.edges.push(ed);
+ }
+ }
+
+ // Vertices
+ for v in 0..s.vertices {
+ let mut vd = VertexData::default();
+ vd.zone_tol_3d = match f.r(VERTEX_TOLERANCE) {
+ [] => zone,
+ t => get(t, v)? / 2.0,
+ };
+ undefined(&mut vd.topology);
+ vd.point = get3(f.r(VERTEX_POSITION), v)?;
+ d.vertices.push(vd);
+ }
+ own_tolerances(&mut d, f, &eu_face, &loop_vertices, n_point)?;
+ Ok(d)
+}
+
+/// The B-rep's and each face's tolerance from the edges' and vertices' own, when the arrays carry
+/// them. TOLERANCE is the largest tolerance in arrays SMLib writes, and MakeTopologyFromData
+/// raises every entity to the B-rep's: taken as the B-rep's, it would become every entity's, and
+/// each crossing between the kernels spread the largest over the whole body. Each written edge or
+/// vertex tolerance is at least the B-rep's and its faces' (`ZoneTolWithFaces`), so the B-rep's is
+/// at most the least of them and a face's at most the least of its edges' and loop vertices'.
+fn own_tolerances(d: &mut BrepData, f: &In<'_>, eu_face: &[usize], loop_vertices: &[(usize, u64)], n_point: u64) -> Result<()> {
+ if f.r(EDGE_TOLERANCE).is_empty() || f.r(VERTEX_TOLERANCE).is_empty() {
+ return Ok(());
+ }
+ let zone = d.zone_tol_3d;
+ let mut faces = vec![f64::INFINITY; d.faces.len()];
+ let mut lower = |face: usize, t: f64| -> Result<()> {
+ let slot = faces.get_mut(face).ok_or_else(|| bad("edgeuse face"))?;
+ *slot = slot.min(t);
+ Ok(())
+ };
+ for (kk, &face) in eu_face.iter().enumerate() {
+ let edge = index_u64(get(f.i(EDGEUSE_EDGE), kk)? as u64)?;
+ lower(face, d.edges.get(edge).ok_or_else(|| bad("edgeuse edge"))?.zone_tol_3d)?;
+ }
+ for &(face, vertex) in loop_vertices {
+ lower(face, d.vertices.get(index_u64(vertex)?).ok_or_else(|| bad("loop vertex"))?.zone_tol_3d)?;
+ }
+ let face_edges = d.edges.len().min(f.r(EDGE_TOLERANCE).len());
+ let own_vertices = d.vertices.iter().skip(index_u64(n_point)?).map(|v| v.zone_tol_3d);
+ let least = d.edges.iter().take(face_edges).map(|e| e.zone_tol_3d).chain(own_vertices).fold(zone, f64::min);
+ if !(least.is_finite() && least > 0.0) {
+ return Ok(());
+ }
+ d.zone_tol_3d = least;
+ for (fd, t) in d.faces.iter_mut().zip(faces) {
+ fd.zone_tol_3d = if t.is_finite() { t.min(zone) } else { zone };
+ }
+ Ok(())
+}
+
+/// An `SmExtent2d` with its ends in order (`Set`, `SetMinMax`).
+fn extent2d(u0: f64, v0: f64, u1: f64, v1: f64) -> Result {
+ if !(u0 <= u1 && v0 <= v1) {
+ return Err(bad("a domain with its ends reversed"));
+ }
+ Ok(Extent2d::from_bounds(u0, v0, u1, v1))
+}
+
+/// Whether `Validate` refuses these arrays.
+#[cfg(all(test, feature = "native"))]
+pub(super) fn validate_refuses(a: &Arrays) -> bool {
+ validate(&In(a)).is_none()
+}
+
+/// A body from the flat arrays the C API imports (`BrepFromFlat`): `Validate`,
+/// `FlatToBrepData`, then `MakeTopologyFromData` with the B-rep's tolerance as it is ([`own_tolerances`]) and no UV
+/// trims or analytic conversion made; it heals unless `SOURCE` says SMLib wrote the arrays.
+pub(crate) fn from_arrays(a: &Arrays) -> Result {
+ let f = In(a);
+ let s = validate(&f).ok_or(Error::Record(crate::import::Unsupported::Invalid("flat arrays")))?;
+ let b = run("BrepFromFlat", |k| Ok(brep_from_flat(k, &f, &s)))??;
+ Ok(Body::new(b))
+}
+
+fn brep_from_flat(k: &mut Kernel, f: &In<'_>, s: &Shape) -> Result {
+ let mut data = flat_to_brep_data(k, f, s)?;
+ // no heal for B-reps SMLib wrote
+ let trusted = f.i(SOURCE).first() == Some(&1);
+ data.set_healer_version(if trusted { HEALER_VERSION } else { HEALER_VERSION - 2 });
+ let mut b = Brep::new_with_infinite_region().map_err(sc("SmBrep::SmBrep"))?;
+ let mut store = AttributeStore::default();
+ let mut records = NotifyRecords::new();
+ let next_key = (data.uv_curves.len() + data.curves_3d.len() + data.surfaces.len()) as u64 + 1;
+ let mut env = TopologyEnv { all_attributes: &[], attributes: &mut store, key_of: &key_of, records: &mut records, next_key };
+ let options = MakeTopologyOptions {
+ make_uv_trim_curves: false,
+ add_analytics: false,
+ force_same_tol: true,
+ check_uv_trim_curves: false,
+ always_heal: false,
+ healer_enabled: true,
+ };
+ make_topology_from_data(k, &mut b, &mut data, &mut env, options).map_err(sc("SmBrep::MakeTopologyFromData"))?;
+ put_enclosing_shells_first(k, &mut b)?;
+ Ok(b)
+}
+
+/// The most `SmShell::IsInnerShellOf` calls one conversion makes; a body that would need more
+/// is refused instead of ray casting without end.
+const MAX_CONTAINMENT_TESTS: usize = 1 << 16;
+
+/// `PutEnclosingShellsFirst` (capi `smlib_flat.cpp`): SMLib takes the first shell of every region
+/// but the infinite one as the shell that encloses the region, the others lying inside it
+/// (`SmBrep::AssertValid` test 12). The arrays may list a region's shells in any order, so the one
+/// that encloses all the others is put first, decided by `SmShell::IsInnerShellOf`
+/// ([`shell::is_inner_shell_of`]). Candidates are the closed face-use shells whose bounding box
+/// can be computed, largest box first (a stable sort: equal boxes keep the arrays' order). Only
+/// `SM_TRUE` counts as inside: `SM_FALSE` and `SM_UNSURE` rule the candidate out, as the capi's
+/// `== TRUE`, and a region where no candidate is shown to enclose the others keeps its order.
+/// solidcore errors the C++ has no counterpart for (a call without a port, a dangling id) refuse
+/// the conversion, as does a body needing more than [`MAX_CONTAINMENT_TESTS`] containment tests.
+fn put_enclosing_shells_first(k: &mut Kernel, b: &mut Brep) -> Result<()> {
+ use solidcore::query::face::p1::calculate_shell_bounding_box;
+ let infinite = b.infinite_region();
+ let mut tests = 0usize;
+ for r in bl::regions(b, None).map_err(sc("GetRegions"))? {
+ if Some(r) == infinite {
+ continue;
+ }
+ let shells = b.region(r).map_err(sc("GetShells"))?.shells.clone();
+ if shells.len() < 2 {
+ continue;
+ }
+ let mut candidates: Vec<(f64, ShellId)> = Vec::new();
+ for &s in &shells {
+ let rec = b.shell(s).map_err(sc("GetShells"))?;
+ if !rec.is_faceuse_shell() || !rec.is_closed(b, s).map_err(sc("SmShell::IsClosed"))? {
+ continue;
+ }
+ match calculate_shell_bounding_box(k, b, s, false) {
+ Ok(bx) => {
+ let size = bx.size();
+ candidates.push((size.x * size.y * size.z, s));
+ }
+ Err(e) if e.is_unported() => return Err(sc("SmShell::CalculateBoundingBox")(e)),
+ // != SM_SUCCESS: not a candidate
+ Err(_) => {}
+ }
+ }
+ // stable: equal boxes keep the arrays' order
+ candidates.sort_by(|a, c| c.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
+ for &(_, candidate) in &candidates {
+ let mut encloses = true;
+ for &s in &shells {
+ if s == candidate {
+ continue;
+ }
+ tests += 1;
+ if tests > MAX_CONTAINMENT_TESTS {
+ return Err(bad("too many shells to order (SmShell::IsInnerShellOf)"));
+ }
+ if shell::is_inner_shell_of(k, b, s, Some(candidate)).map_err(sc("SmShell::IsInnerShellOf"))? != shell::SM_TRUE {
+ encloses = false;
+ break;
+ }
+ }
+ if encloses {
+ // SmRegion::SetList(candidate): the circular list now starts at it
+ let list = &mut b.region_mut(r).map_err(sc("SmRegion::SetList"))?.shells;
+ if let Some(at) = list.iter().position(|&s| s == candidate) {
+ list.rotate_left(at);
+ }
+ break;
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/crates/smlib/src/core/convert_tests.rs b/crates/smlib/src/core/convert_tests.rs
new file mode 100644
index 0000000000..c2754d75ca
--- /dev/null
+++ b/crates/smlib/src/core/convert_tests.rs
@@ -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::>(), y.keys().collect::>(), "{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::>() {
+ 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::>() {
+ 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 {
+ 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> {
+ let reach = |s: u32| {
+ let Some(b::Shell::Faces { faces, .. }) = rec.shells.get(s as usize) else { panic!("shell {s}") };
+ let mut r = 0.0f64;
+ for f in faces {
+ for l in &rec.faces[f.face as usize].loops {
+ let b::Loop::Edges { edges } = l else { continue };
+ for e in edges {
+ for &v in &rec.edges[e.edge as usize].vertices {
+ r = rec.vertices[v as usize].point.iter().fold(r, |r, c| r.max(c.abs()));
+ }
+ }
+ }
+ }
+ r
+ };
+ rec.regions.iter().skip(1).map(|r| r.shells.iter().map(|&s| reach(s)).collect()).collect()
+}
+
+/// The sealed void's record: the solid region lists the outer box first, then the void's box;
+/// the void region its box alone.
+fn outer_first(a: &Arrays, at: &str) {
+ let mut got = reaches(&record(a));
+ got.sort_by_key(|x| std::cmp::Reverse(x.len()));
+ assert_eq!(got, [vec![10.0, 5.0], vec![5.0]], "{at}");
+}
+
+/// The arrays with every region's shells listed in reverse: each shell's block (its counts and
+/// its face uses) moved, nothing else touched.
+fn shells_reversed(a: &Arrays) -> Arrays {
+ use crate::flat::{FACEUSE_FACE, FACEUSE_ORIENTATION, REGION_SHELL_COUNT, SHELL_FACEUSE_COUNT, SHELL_POINT_TYPE, SHELL_WIRE_EDGE_COUNT};
+ let mut p = a.clone();
+ let blocks = [SHELL_FACEUSE_COUNT, SHELL_WIRE_EDGE_COUNT, SHELL_POINT_TYPE, FACEUSE_FACE, FACEUSE_ORIENTATION];
+ for id in blocks {
+ p.ints.get_mut(&id).unwrap().clear();
+ }
+ let (mut shell, mut fu) = (0usize, 0usize);
+ for &n in &a.ints[®ION_SHELL_COUNT] {
+ let mut region = Vec::new();
+ for _ in 0..n {
+ let uses = usize::try_from(a.ints[&SHELL_FACEUSE_COUNT][shell]).unwrap();
+ region.push((shell, fu..fu + uses));
+ shell += 1;
+ fu += uses;
+ }
+ for (s, uses) in region.into_iter().rev() {
+ for id in [SHELL_FACEUSE_COUNT, SHELL_WIRE_EDGE_COUNT, SHELL_POINT_TYPE] {
+ let v = a.ints[&id][s];
+ p.ints.get_mut(&id).unwrap().push(v);
+ }
+ for id in [FACEUSE_FACE, FACEUSE_ORIENTATION] {
+ p.ints.get_mut(&id).unwrap().extend_from_slice(&a.ints[&id][uses.clone()]);
+ }
+ }
+ }
+ assert_ne!(p.ints, a.ints, "nothing to reverse");
+ p
+}
+
+/// solidcore's sealed void exports with its outer shell first, comes back through the arrays
+/// with it first, and keeps its volume.
+#[test]
+fn a_sealed_void_crosses_the_arrays_outer_shell_first() {
+ let body = sealed_void();
+ let v = volume(&body, 1e-9).unwrap();
+ assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "{v}");
+ let a = to_arrays(&body).unwrap();
+ outer_first(&a, "export");
+ let back = from_arrays(&import::arrays(&record(&a), true).unwrap()).unwrap();
+ outer_first(&to_arrays(&back).unwrap(), "round trip");
+ let v = volume(&back, 1e-9).unwrap();
+ assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "round trip: {v}");
+}
+
+/// Arrays listing the solid region's void shell before the box around it import with the box
+/// first (SMLib takes a region's first shell as its enclosing one), the volume unchanged.
+#[test]
+fn shells_listed_inner_first_import_outer_first() {
+ let a = shells_reversed(&to_arrays(&sealed_void()).unwrap());
+ let back = from_arrays(&a).unwrap();
+ outer_first(&to_arrays(&back).unwrap(), "inner first");
+ let v = volume(&back, 1e-9).unwrap();
+ assert!(rel(v, SEALED_VOID_VOLUME) < 1e-9, "inner first: {v}");
+}
+
+/// The C++ kernel's sealed void: solidcore imports its arrays with the outer shell first, in
+/// either shell order, at the C++ kernel's volume, and the C++ kernel reads solidcore's arrays
+/// (either order) back as a body that passes its checks at that volume.
+#[cfg(feature = "native")]
+#[test]
+fn a_sealed_void_matches_the_cpp_kernel() {
+ let _cpp = crate::route::on_cpp();
+ use crate::{Brep, ffi};
+ crate::init().unwrap();
+ let make = |o: f64, s: f64| ffi::make_box(&[o; 3], &[s; 3]).map(Brep::made).unwrap();
+ let (outer, inner) = (make(-10.0, 20.0), make(-5.0, 10.0));
+ let cpp = Brep::made(ffi::ops::boolean(outer.cpp().unwrap(), inner.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap());
+ let theirs = ffi::volume(cpp.cpp().unwrap(), 1e-9).unwrap();
+ assert!(rel(theirs, SEALED_VOID_VOLUME) < 1e-9, "C++ {theirs}");
+ let arrays = cpp.cpp_arrays().unwrap();
+ outer_first(&arrays, "C++ export");
+ for (at, a) in [("C++ arrays", arrays.clone()), ("C++ arrays, inner first", shells_reversed(&arrays))] {
+ let body = from_arrays(&a).unwrap();
+ outer_first(&to_arrays(&body).unwrap(), at);
+ let v = volume(&body, 1e-9).unwrap();
+ assert!(rel(v, theirs) < 1e-9, "{at}: solidcore {v} vs C++ {theirs}");
+ }
+ let ours = to_arrays(&sealed_void()).unwrap();
+ for (at, a) in [("solidcore arrays", ours.clone()), ("solidcore arrays, inner first", shells_reversed(&ours))] {
+ let h = crate::flat::Flat::from_arrays(&a).unwrap().import().unwrap();
+ let v = ffi::volume(h.cpp().unwrap(), 1e-9).unwrap();
+ assert!(rel(v, theirs) < 1e-9, "{at}: C++ {v} vs {theirs}");
+ let mut buf = vec![0u8; 1 << 16];
+ let (len, failures) = ffi::check::check(h.cpp().unwrap(), 2, &mut buf).unwrap();
+ assert_eq!(failures, 0, "{at}: {}", String::from_utf8_lossy(&buf[..len]));
+ outer_first(&h.cpp_arrays().unwrap(), &format!("{at}, C++ export"));
+ }
+}
+
+/// C++ bodies through solidcore and back. Three crossings of the same C++ arrays are held equal,
+/// ints exactly and reals to 1e-12 (the record too, where the record can say it):
+/// - the C++ kernel's own: its import of them, exported again;
+/// - solidcore's: `from_arrays`, then `to_arrays`;
+/// - both: solidcore's arrays imported by the C++ kernel and exported again.
+///
+/// The first is the reference, not the arrays that went in: a crossing itself moves a few reals on
+/// the C++ kernel too (UV trims made at export, about 1e-7 on a shelled box), so those are held to
+/// the arrays that went in only at [`FIRST_CROSSING`]. Tolerances are excepted: they cross on
+/// purpose as each entity's own (`own_tolerances`, TOLERANCE as twice the B-rep's) where the C++
+/// export writes the largest over them all, so that a body does not grow its tolerance at every
+/// crossing (`tolerances_cross_the_arrays_per_entity`); on a body an operation made the numbers
+/// cannot agree. So is EXTENT, the bounding box padded by those tolerances.
+#[cfg(feature = "native")]
+mod cpp_round_trip {
+ use serde_json::Value;
+ use solidcraft_geom::brep as b;
+
+ use super::{close, from_arrays, native, rel, to_arrays, volume};
+ use crate::flat::{self, EDGE_TOLERANCE, EXTENT, Flat, TOLERANCE, VERTEX_TOLERANCE};
+ use crate::import::Arrays;
+ use crate::sweeps::{Curve, DraftCorner, SweepFrame};
+ use crate::{Brep, ffi};
+
+ const PICK_TOL: f64 = 1e-6;
+
+ /// How far any crossing, the C++ kernel's own included, may move a real of the arrays that
+ /// went in.
+ const FIRST_CROSSING: f64 = 1e-6;
+
+ fn cpp_box(o: [f64; 3], s: [f64; 3]) -> Brep {
+ crate::init().unwrap();
+ Brep::made(ffi::make_box(&o, &s).unwrap())
+ }
+
+ fn cpp_cylinder(o: [f64; 3], r: f64, h: f64) -> Brep {
+ crate::init().unwrap();
+ Brep::made(ffi::make_cylinder(&o, r, h).unwrap())
+ }
+
+ fn cut(a: &Brep, b: &Brep) -> Brep {
+ Brep::made(ffi::ops::boolean(a.cpp().unwrap(), b.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap())
+ }
+
+ /// `f` with the sweeps on the C++ kernel, whatever the choice.
+ fn on_cpp(f: impl FnOnce() -> crate::Result) -> Brep {
+ 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::>(), got.ints.keys().collect::>(), "{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::>(), got.reals.keys().collect::>(), "{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 = (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 = (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); 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);
+ }
+}
diff --git a/crates/smlib/src/core/dump.rs b/crates/smlib/src/core/dump.rs
new file mode 100644
index 0000000000..84fc9d8840
--- /dev/null
+++ b/crates/smlib/src/core/dump.rs
@@ -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:
+//! `-.txt`, what failed, and `-- .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> = 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, String>)>> = Mutex::new(Vec::new());
+
+/// Where failing calls leave their inputs; `None` stops it.
+pub(crate) fn set_dir(dir: Option) {
+ *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 {
+ None
+ }
+}
+
+impl Answer for Result {
+ fn refusal(&self) -> Option {
+ 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,
+ Vec<[f64; 3]>,
+ Brep,
+ solidcore::geom::vector3d::Point3,
+ crate::history::Raw,
+ crate::MassProperties
+);
+
+impl Answer for Option {}
+
+/// 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 {
+ let n = NAME_LEN.load(Ordering::SeqCst);
+ if n == 0 {
+ return None;
+ }
+ let bytes: Vec = NAME.iter().take(n).map(|b| b.load(Ordering::Relaxed)).collect();
+ Some(String::from_utf8_lossy(&bytes).into_owned())
+}
diff --git a/crates/smlib/src/core/file.rs b/crates/smlib/src/core/file.rs
new file mode 100644
index 0000000000..2164ffee16
--- /dev/null
+++ b/crates/smlib/src/core/file.rs
@@ -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 {
+ 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> {
+ 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))
+}
diff --git a/crates/smlib/src/core/history.rs b/crates/smlib/src/core/history.rs
new file mode 100644
index 0000000000..e11b73b388
--- /dev/null
+++ b/crates/smlib/src/core/history.rs
@@ -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,
+ edges: BTreeSet,
+ at: Vec,
+}
+
+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::().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, e: &BTreeSet) -> Vec {
+ 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, e: BTreeSet, p: Vec) -> Vec {
+ 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> {
+ 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> {
+ 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> {
+ 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::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, at: Vec) -> 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, 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 {
+ 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 = 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 {
+ 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 = edges.iter().enumerate().map(|(i, e)| (*e, i)).collect();
+ for (i, f) in faces.iter().enumerate() {
+ let own: BTreeSet =
+ 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 {
+ 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 = 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> = Vec::with_capacity(faces.len());
+ let mut fe: Vec> = Vec::with_capacity(faces.len());
+ let mut eo: Vec> = Vec::with_capacity(edges.len());
+ let mut at: Vec> = Vec::with_capacity(edges.len());
+ let mut face_uses: BTreeMap = BTreeMap::new();
+ let mut edge_uses: BTreeMap = 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> = 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 = 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 })
+ })
+}
diff --git a/crates/smlib/src/core/measure.rs b/crates/smlib/src/core/measure.rs
new file mode 100644
index 0000000000..ffeb034d63
--- /dev/null
+++ b/crates/smlib/src/core/measure.rs
@@ -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 {
+ 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 {
+ let void_side = |u: Option| -> solidcore::Result {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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()`: `::Test # :