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 # : [owner ]`, the owner +/// tag only when the report names the owner's type. +fn report_line(r: &AssertReport) -> String { + let msg = r.format_log_message(); + let mut s = format!("{}::Test #{} {}: {}", text(obj_type_string(r.reporting_type)), r.test_index, text(r.name), text(&msg)); + if let Some(owner) = r.owner_type_string.filter(|o| !o.is_empty()) { + s.push_str(" [owner "); + s.push_str(owner); + s.push(']'); + } + s +} + +/// The capi's `collect()`: appends the failed reports of `list`, one line each, and counts them; +/// SMLib's warnings ([`AssertReport::is_warning`]: `SmBrep` test 21, "Suspicious Shell Count", a +/// body of several faces with one shell each, is one) are neither. +fn collect(list: &AssertArray, out: &mut String, failures: &mut usize) { + for r in list.reports().iter().filter(|r| !r.ok && !r.is_warning()) { + *failures += 1; + out.push_str(&report_line(r)); + out.push('\n'); + } +} + +/// Why [`check`] could not run. +enum Refusal { + /// A test solidcore has not ported (its [`SolidError::Unported`]). + Unported(&'static str, &'static str), + /// A body whose geometry owners' identities the check cannot know: a + /// [`NamingScheme::Unknown`] body. + Naming(NamingScheme), +} + +impl super::dump::Answer for std::result::Result { + fn refusal(&self) -> Option { + self.is_err().then(|| "the check could not run".to_string()) + } +} + +/// `smlib_brep_check` (`smlib_check.cpp`): `SmBrep::ValidatePointers` at `level`, then, when it +/// failed nothing, `SmBrep::AssertValid` at `level` over the whole topology graph, with every +/// failed test as one line of the report and the capi's own lines for a failed status without a +/// report. solidcore's [`check_brep`] runs the same two calls. +/// +/// The geometry owners are named as the body's naming scheme names them: `check_brep` reads a +/// [`NamingScheme::Caller`] body's keys from where its maker recorded them (solidcore#1585: an +/// array import through `make_topology_from_data`, `api_brep_copy`); where it recorded none, the +/// arena identities the binders name such an entity by. A [`NamingScheme::Unknown`] body would be +/// checked under arena identities no maker uses: every edge would fail `SmBrep` test 61 on the +/// owner of its curve. +/// +/// A test solidcore has not ported yet (its [`SolidError::Unported`]), or a body whose geometry +/// owners' identities the check cannot know, fails the check with "not supported yet": the check +/// never reports a body valid on fewer tests than the C++ kernel runs. +pub(crate) fn check(body: &Body, level: CheckLevel) -> Result { + let lv = match level { + CheckLevel::Basic => SolidcoreCheckLevel::Level0, + CheckLevel::Geometry => SolidcoreCheckLevel::Level1, + CheckLevel::All => SolidcoreCheckLevel::Level2, + }; + let c = match with_body("check", body, |k, b| { + let naming = b.naming(); + if naming == NamingScheme::Unknown { + return Ok(Err(Refusal::Naming(naming))); + } + match check_brep(k, b, lv) { + Err(SolidError::Unported { upstream, unit }) => Ok(Err(Refusal::Unported(upstream, unit))), + r => r.map(Ok), + } + })? { + Ok(c) => c, + Err(Refusal::Unported(upstream, unit)) => { + return Err(Error::Solidcore(format!("smlib_brep_check: {upstream}: not supported yet: solidcore has not ported it (unit {unit})"))); + } + Err(Refusal::Naming(n)) => { + return Err(Error::Solidcore(format!( + "smlib_brep_check: not supported yet: the body names its geometry owners by its maker's keys (NamingScheme::{n:?}), which the check cannot know" + ))); + } + }; + let mut text = String::new(); + let mut failures = 0usize; + collect(&c.pointer_reports, &mut text, &mut failures); + if c.pointers_status != SM_SUCCESS && failures == 0 { + failures += 1; + text.push_str(&format!("SmBrep::ValidatePointers failed with status {}\n", c.pointers_status)); + } + if failures > 0 { + // The capi does not run AssertValid after a failed pointer check. check_brep runs it when + // the status failed without a report; that result is dropped here, as the capi never + // computes it. + text.push_str("AssertValid skipped: ValidatePointers failed\n"); + } else { + let Some(ok) = c.assert_valid else { + return Err(Error::Solidcore("smlib_brep_check: AssertValid did not run after a clean pointer check".into())); + }; + collect(&c.reports, &mut text, &mut failures); + if !ok && failures == 0 { + failures += 1; + text.push_str("SmBrep::AssertValid failed without a report\n"); + } + } + Ok(CheckReport { failures, text }) +} diff --git a/crates/smlib/src/core/measure_tests.rs b/crates/smlib/src/core/measure_tests.rs new file mode 100644 index 0000000000..418c02fccd --- /dev/null +++ b/crates/smlib/src/core/measure_tests.rs @@ -0,0 +1,511 @@ +//! The Measure and Check families on solidcore's primitives: mass properties, area and bounds +//! against their closed forms ([`crate::measure::closed`]), one solid each, edges on their faces; +//! and (with SMLib built) the same answers as the C++ kernel for the same primitive. + +use super::{Body, area, bounds, check, edge_face_distance, is_manifold_solid, make_box, make_cone, make_cylinder, make_sphere, make_torus}; +use super::{counts, mass_properties, solid_count}; +use crate::check::CheckLevel; +use crate::measure::MEASURE_ACCURACY; +use crate::measure::closed::{self, Closed}; + +/// The tolerance every quantity meets, relative. +const TOL: f64 = 1e-9; + +/// The primitives, as `convert_tests` makes them, with their closed forms. +fn case(name: &str) -> (Body, Closed) { + let (body, c) = match name { + "box" => (make_box([1.0, -2.0, 3.0], [10.0, 20.0, 30.0]), closed::cuboid([1.0, -2.0, 3.0], [10.0, 20.0, 30.0])), + "cylinder" => (make_cylinder([1.0, 2.0, -3.0], 5.0, 12.0), closed::cylinder([1.0, 2.0, -3.0], 5.0, 12.0)), + "sphere" => (make_sphere([1.0, 2.0, 3.0], 7.0), closed::sphere([1.0, 2.0, 3.0], 7.0)), + "cone" => (make_cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0), closed::cone([0.0, 1.0, 2.0], 6.0, 0.0, 9.0)), + "frustum" => (make_cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0), closed::cone([0.0, 1.0, 2.0], 6.0, 2.0, 9.0)), + "torus" => (make_torus([1.0, -1.0, 0.5], 10.0, 3.0), closed::torus([1.0, -1.0, 0.5], 10.0, 3.0)), + _ => panic!("{name}"), + }; + (body.unwrap(), c) +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +/// The body's size: twice its largest radius of gyration, the scale of centroid errors. +fn size(c: &Closed) -> f64 { + 2.0 * (c.moments.iter().fold(0.0f64, |a, &b| a.max(b)) / c.volume).sqrt() +} + +fn mass_properties_match(name: &str) { + let (b, c) = case(name); + let m = mass_properties(&b, MEASURE_ACCURACY).unwrap(); + assert!(rel(m.area, c.area) < TOL, "{name}: area {} vs {}", m.area, c.area); + assert!(rel(m.volume, c.volume) < TOL, "{name}: volume {} vs {}", m.volume, c.volume); + let imax = c.moments.iter().fold(0.0f64, |a, &b| a.max(b)); + for i in 0..3 { + assert!((m.centroid[i] - c.centroid[i]).abs() < TOL * size(&c), "{name}: centroid {:?} vs {:?}", m.centroid, c.centroid); + assert!(rel(m.moments[i], c.moments[i]) < TOL, "{name}: moments {:?} vs {:?}", m.moments, c.moments); + assert!(m.products[i].abs() < TOL * imax, "{name}: products {:?}", m.products); + } +} + +fn area_matches(name: &str) { + let (b, c) = case(name); + let a = area(&b, MEASURE_ACCURACY).unwrap(); + assert!(rel(a, c.area) < TOL, "{name}: area {a} vs {}", c.area); +} + +fn bounds_match(name: &str) { + let (b, c) = case(name); + let (lo, hi) = bounds(&b).unwrap(); + let s = size(&c); + for i in 0..3 { + assert!((lo[i] - c.bounds.0[i]).abs() < TOL * s, "{name}: low {lo:?} vs {:?}", c.bounds.0); + assert!((hi[i] - c.bounds.1[i]).abs() < TOL * s, "{name}: high {hi:?} vs {:?}", c.bounds.1); + } +} + +fn one_solid(name: &str) { + let (b, _) = case(name); + assert_eq!(solid_count(&b).unwrap(), 1, "{name}"); +} + +fn manifold_solid(name: &str) { + let (b, _) = case(name); + assert!(is_manifold_solid(&b).unwrap(), "{name}"); +} + +/// Every edge lies on the faces it bounds: its distance to the nearest face is 0. +fn edges_lie_on_their_faces(name: &str) { + let (b, c) = case(name); + let [faces, edges, _] = counts(&b).unwrap(); + for e in 0..edges { + let mut d: Vec = (0..faces).filter_map(|f| edge_face_distance(&b, e, f).ok()).collect(); + d.sort_by(f64::total_cmp); + assert!(d.first().is_some_and(|&d| d < TOL * size(&c)), "{name}: edge {e}: {d:?}"); + } + assert!(edge_face_distance(&b, edges, 0).is_err() && edge_face_distance(&b, 0, faces).is_err()); +} + +fn checks_valid(name: &str) { + let (b, _) = case(name); + for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] { + let r = check(&b, level).unwrap(); + assert!(r.is_valid(), "{name}, {level:?}: {r}"); + } +} + +/// Refusing is decided on the body's naming scheme alone. An Unknown body is refused rather than +/// reported invalid on every edge; a Caller one without recorded keys is checked under the arena +/// identities (`check_brep` has no error for it, so no error of its can turn into a refusal). +#[test] +fn check_refuses_only_an_unknown_naming() { + use solidcore::topology::NamingScheme; + let name = |naming| { + let (b, _) = case("box"); + b.0.lock().unwrap().set_naming(naming); + check(&b, CheckLevel::All) + }; + let refused = name(NamingScheme::Unknown).unwrap_err().to_string(); + assert!(refused.contains("not supported yet") && refused.contains("NamingScheme::Unknown"), "{refused}"); + name(NamingScheme::Caller).unwrap(); +} + +/// The C++ kernel's body of the same primitive, built by its C API whichever kernel is in +/// force: a body imported from solidcore's arrays is not the C++ kernel's own (its +/// `edge_face_distance` measures to each face's surface over its domain, which the import sets). +#[cfg(feature = "native")] +fn cpp(name: &str) -> crate::Brep { + use crate::{Brep, ffi}; + crate::init().unwrap(); + match name { + "box" => ffi::make_box(&[1.0, -2.0, 3.0], &[10.0, 20.0, 30.0]), + "cylinder" => ffi::make_cylinder(&[1.0, 2.0, -3.0], 5.0, 12.0), + "sphere" => ffi::make_sphere(&[1.0, 2.0, 3.0], 7.0), + "cone" => ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 0.0, 9.0), + "frustum" => ffi::make_cone(&[0.0, 1.0, 2.0], 6.0, 2.0, 9.0), + "torus" => ffi::make_torus(&[1.0, -1.0, 0.5], 10.0, 3.0), + _ => panic!("{name}"), + } + .map(Brep::made) + .unwrap() +} + +/// `x` within `TOL` of the C++ kernel's `y`, relative to `scale`. +#[cfg(feature = "native")] +fn same(x: f64, y: f64, scale: f64, what: &str) { + assert!((x - y).abs() <= TOL * scale, "{what}: solidcore {x} vs C++ {y}"); +} + +#[cfg(feature = "native")] +fn mass_properties_parity(name: &str) { + let (b, c) = case(name); + let ours = mass_properties(&b, MEASURE_ACCURACY).unwrap(); + let theirs = cpp(name).mass_properties(MEASURE_ACCURACY).unwrap(); + same(ours.area, theirs.area, theirs.area, &format!("{name} area")); + same(ours.volume, theirs.volume, theirs.volume, &format!("{name} volume")); + let imax = theirs.moments.iter().fold(0.0f64, |a, &b| a.max(b)); + for i in 0..3 { + same(ours.centroid[i], theirs.centroid[i], size(&c), &format!("{name} centroid[{i}]")); + same(ours.moments[i], theirs.moments[i], theirs.moments[i], &format!("{name} moments[{i}]")); + same(ours.products[i], theirs.products[i], imax, &format!("{name} products[{i}]")); + } +} + +#[cfg(feature = "native")] +fn area_parity(name: &str) { + let (b, _) = case(name); + let theirs = cpp(name).area(MEASURE_ACCURACY).unwrap(); + same(area(&b, MEASURE_ACCURACY).unwrap(), theirs, theirs, &format!("{name} area")); +} + +#[cfg(feature = "native")] +fn bounds_parity(name: &str) { + let (b, c) = case(name); + let (lo, hi) = bounds(&b).unwrap(); + let (tlo, thi) = cpp(name).bounds().unwrap(); + for i in 0..3 { + same(lo[i], tlo[i], size(&c), &format!("{name} low[{i}]")); + same(hi[i], thi[i], size(&c), &format!("{name} high[{i}]")); + } +} + +#[cfg(feature = "native")] +fn solid_count_parity(name: &str) { + let (b, _) = case(name); + assert_eq!(solid_count(&b).unwrap(), cpp(name).solid_count().unwrap(), "{name}"); +} + +#[cfg(feature = "native")] +fn manifold_parity(name: &str) { + let (b, _) = case(name); + assert_eq!(is_manifold_solid(&b).unwrap(), cpp(name).is_manifold_solid().unwrap(), "{name}"); +} + +/// The whole edge-by-face distance matrix, the C++ kernel's own (both bodies list their edges +/// and faces in the same order: `convert_tests` shows their arrays are equal). +#[cfg(feature = "native")] +fn distance_parity(name: &str) { + let (b, c) = case(name); + let theirs = cpp(name); + let [faces, edges, _] = counts(&b).unwrap(); + for e in 0..edges { + for f in 0..faces { + let (x, y) = (edge_face_distance(&b, e, f), theirs.edge_face_distance(e, f)); + match (x, y) { + (Ok(x), Ok(y)) => same(x, y, size(&c), &format!("{name} edge {e} face {f}")), + (x, y) => assert_eq!(x.is_ok(), y.is_ok(), "{name} edge {e} face {f}: {x:?} vs {y:?}"), + } + } + } +} + +#[cfg(feature = "native")] +fn check_parity(name: &str) { + let (b, _) = case(name); + for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] { + assert_eq!(check(&b, level).unwrap(), cpp(name).check(level).unwrap(), "{name}, {level:?}"); + } +} + +macro_rules! primitive { + ($m:ident, $name:literal $(, mass: $mass:meta)? $(, area: $area:meta)?) => { + mod $m { + use super::*; + #[test] + $(#[$mass])? + fn mass_properties_match_the_closed_forms() { + mass_properties_match($name); + } + #[test] + $(#[$area])? + fn area_matches_the_closed_form() { + area_matches($name); + } + #[test] + fn bounds_match_the_closed_form() { + bounds_match($name); + } + #[test] + fn is_one_solid() { + one_solid($name); + } + #[test] + fn is_a_manifold_solid() { + manifold_solid($name); + } + #[test] + fn edges_lie_on_their_faces() { + super::edges_lie_on_their_faces($name); + } + #[test] + fn checks_valid() { + super::checks_valid($name); + } + #[cfg(feature = "native")] + #[test] + $(#[$mass])? + fn mass_properties_match_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + mass_properties_parity($name); + } + #[cfg(feature = "native")] + #[test] + $(#[$area])? + fn area_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + area_parity($name); + } + #[cfg(feature = "native")] + #[test] + fn bounds_match_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + bounds_parity($name); + } + #[cfg(feature = "native")] + #[test] + fn solid_count_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + solid_count_parity($name); + } + #[cfg(feature = "native")] + #[test] + fn manifold_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + manifold_parity($name); + } + #[cfg(feature = "native")] + #[test] + fn distances_match_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + distance_parity($name); + } + #[cfg(feature = "native")] + #[test] + fn check_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + check_parity($name); + } + } + }; +} + +primitive!(box_, "box"); +primitive!(cylinder, "cylinder"); +primitive!(sphere, "sphere"); +primitive!(cone, "cone"); +primitive!(frustum, "frustum"); +primitive!(torus, "torus"); + +/// The head of each report line, `::Test #`, or the whole line for the capi's +/// own lines (a failed status without a report, a skipped AssertValid). +#[cfg(feature = "native")] +fn tests_of(text: &str) -> Vec<&str> { + text.lines().map(|l| l.find(" #").and_then(|i| l[i + 2..].find(' ').map(|j| &l[..i + 2 + j])).unwrap_or(l)).collect() +} + +/// A valid box passes every test at level 2 (SolidCraft's level), with an empty report, on +/// solidcore and on the C++ kernel. +#[test] +fn a_valid_box_passes_level_2() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + let (b, _) = case("box"); + let r = check(&b, CheckLevel::All).unwrap(); + assert_eq!((r.failures, r.text.as_str()), (0, ""), "{r}"); + #[cfg(feature = "native")] + { + let r = cpp("box").check(CheckLevel::All).unwrap(); + assert_eq!((r.failures, r.text.as_str()), (0, ""), "{r}"); + } +} + +/// A box with one corner moved 1 mm off its edges, through the arrays: the check finds it broken, +/// and the C++ kernel fails the same tests on the same arrays. +#[test] +fn a_vertex_off_its_edges_fails_the_check() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + use crate::flat::VERTEX_POSITION; + let (b, _) = case("box"); + let mut a = super::to_arrays(&b).unwrap(); + a.reals.get_mut(&VERTEX_POSITION).unwrap()[0] += 1.0; + let broken = super::from_arrays(&a).unwrap(); + let r = check(&broken, CheckLevel::All).unwrap(); + assert!(!r.is_valid(), "{r}"); + assert_eq!(r.failures, r.text.lines().filter(|l| l.contains("::Test #")).count(), "{r}"); + #[cfg(feature = "native")] + { + let cpp = crate::flat::Flat::from_arrays(&a).unwrap().import().unwrap(); + let c = cpp.check(CheckLevel::All).unwrap(); + assert_eq!(tests_of(&r.text), tests_of(&c.text), "solidcore:\n{}\nC++:\n{}", r.text, c.text); + assert_eq!(r, c); + } +} + +#[test] +fn hostile_numbers_are_refused() { + let (b, _) = case("box"); + assert_eq!(mass_properties(&b, f64::NAN).unwrap_err(), crate::Error::Argument); + assert_eq!(area(&b, f64::INFINITY).unwrap_err(), crate::Error::Argument); + assert_eq!(edge_face_distance(&b, usize::MAX, 0).unwrap_err(), crate::Error::Argument); + // Out of range is clamped, not refused. + assert!(rel(mass_properties(&b, 1e-30).unwrap().volume, 6000.0) < TOL); + assert!(rel(mass_properties(&b, 10.0).unwrap().volume, 6000.0) < TOL); +} + +/// A 20 mm box minus a centred 10 mm box (a sealed void), as solidcore's boolean makes it and as +/// it comes back from arrays listing its solid region's shells inner first. +fn sealed_voids() -> [(&'static str, Body); 2] { + use crate::BooleanOp; + use crate::flat::{FACEUSE_FACE, FACEUSE_ORIENTATION, REGION_SHELL_COUNT, SHELL_FACEUSE_COUNT}; + let body = super::boolean::boolean(&make_box([-10.0; 3], [20.0; 3]).unwrap(), &make_box([-5.0; 3], [10.0; 3]).unwrap(), BooleanOp::Subtract) + .unwrap() + .unwrap(); + // The bounded solid region of two shells: swap their blocks (the shell counts match, as do + // the face-use counts, six faces each), so only the face uses move. + let mut a = super::to_arrays(&body).unwrap(); + let regions = &a.ints[®ION_SHELL_COUNT]; + let first = regions.iter().position(|&n| n == 2).unwrap(); + let shell: usize = regions[..first].iter().map(|&n| usize::try_from(n).unwrap()).sum(); + let counts = &a.ints[&SHELL_FACEUSE_COUNT]; + assert_eq!((counts[shell], counts[shell + 1]), (6, 6)); + let fu: usize = counts[..shell].iter().map(|&n| usize::try_from(n).unwrap()).sum(); + for id in [FACEUSE_FACE, FACEUSE_ORIENTATION] { + let v = a.ints.get_mut(&id).unwrap(); + let (x, y) = v[fu..fu + 12].split_at_mut(6); + x.swap_with_slice(y); + } + let reordered = super::from_arrays(&a).unwrap(); + [("solidcore's", body), ("inner first", reordered)] +} + +/// The sealed void's mass properties: 8000 − 1000 to 1e-9, centroid at the origin, one solid, +/// in either shell order; with SMLib built, the C++ kernel's own. +#[test] +fn a_sealed_void_measures_its_volume() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + let want = 8000.0 - 1000.0; + // Ixx of a cube of side s about its centre: s⁵ / 6. + let moment = (20.0f64.powi(5) - 10.0f64.powi(5)) / 6.0; + #[cfg(feature = "native")] + let theirs = { + use crate::{Brep, ffi}; + crate::init().unwrap(); + let make = |o: f64, s: f64| ffi::make_box(&[o; 3], &[s; 3]).map(Brep::made).unwrap(); + let (outer, inner) = (make(-10.0, 20.0), make(-5.0, 10.0)); + let h = ffi::ops::boolean(outer.cpp().unwrap(), inner.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap(); + ffi::volume(&h, 1e-9).unwrap() + }; + for (at, b) in sealed_voids() { + let m = mass_properties(&b, MEASURE_ACCURACY).unwrap(); + assert!(rel(m.volume, want) < TOL, "{at}: volume {}", m.volume); + assert!(m.centroid.iter().all(|c| c.abs() < TOL * 20.0), "{at}: centroid {:?}", m.centroid); + assert!(m.moments.iter().all(|&i| rel(i, moment) < TOL), "{at}: moments {:?} vs {moment}", m.moments); + assert_eq!(solid_count(&b).unwrap(), 1, "{at}"); + #[cfg(feature = "native")] + same(m.volume, theirs, theirs, &format!("{at} volume")); + } +} + +/// Two 10 mm squares apart in z = 0, one body of two faces with a shell each (as the top and +/// front of a box offset apart across their edge make, kernel case +/// `surfaces/cov-offset-two-faces-across-a-sharp-edge-come-apart`): `SmBrep` test 21, +/// "Suspicious Shell Count", warns of it, and the capi's check counts no warning. +#[test] +fn faces_apart_with_a_shell_each_check_valid() { + use solidcore::geom::axis2_placement::Axis2Placement; + use solidcore::geom::vector3d::Vec3; + use solidcore::primitive::primitive_creation::p1::create_rectangle; + + use super::boolean::boolean2d; + use super::run; + use crate::BooleanOp; + + let square = |x: f64| { + let frame = Axis2Placement::new(Vec3::new(x, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0)); + run("create_rectangle", |k| create_rectangle(k, 0.0, 10.0, 10.0, &frame)).map(Body::new).unwrap() + }; + let b = boolean2d(&square(0.0), &square(20.0), BooleanOp::Union).unwrap().unwrap(); + assert_eq!(counts(&b).unwrap()[0], 2); + for level in [CheckLevel::Basic, CheckLevel::Geometry, CheckLevel::All] { + let r = check(&b, level).unwrap(); + assert!(r.is_valid(), "{level:?}: {r}"); + } +} + +/// `smlib_brep_classify_point` (fork 34d6b2b), the capi test's cases: a 10 x 20 x 30 box at +/// `(1, -2, 3)`: inside, outside, on a face, an edge and a vertex; the cylinder of radius 5 about +/// the z axis through `(1, 2)`: off its curved wall by 1e-8 on it (within the zone tolerance), by +/// 1e-4 inside or outside, on its axis inside, on its top's centre on it. With SMLib built, the +/// C++ kernel's own primitives classify the same. +#[test] +fn points_classify_as_the_capi() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + use super::classify_point; + use crate::measure::PointClass::{Inside, OnBoundary, Outside}; + + let (bx, _) = case("box"); + let (cyl, _) = case("cylinder"); + let c45 = std::f64::consts::FRAC_1_SQRT_2; + let wall = |r: f64| [1.0 + r * c45, 2.0 + r * c45, 3.0]; + let cases = [ + ("box", [6.0, 8.0, 18.0], Inside), + ("box", [6.0, 8.0, 34.0], Outside), + ("box", [0.0, -3.0, 2.0], Outside), + ("box", [6.0, 8.0, 33.0], OnBoundary), + ("box", [11.0, 8.0, 33.0], OnBoundary), + ("box", [11.0, 18.0, 33.0], OnBoundary), + ("box", [1.0, -2.0, 3.0], OnBoundary), + ("cylinder", wall(5.0 + 1e-8), OnBoundary), + ("cylinder", wall(5.0 - 1e-8), OnBoundary), + ("cylinder", wall(5.0 + 1e-4), Outside), + ("cylinder", wall(5.0 - 1e-4), Inside), + ("cylinder", [1.0, 2.0, 3.0], Inside), + ("cylinder", [1.0, 2.0, 9.0], OnBoundary), + ]; + for (name, p, want) in cases { + let body = if name == "box" { &bx } else { &cyl }; + assert_eq!(classify_point(body, p).unwrap(), want, "{name} {p:?}"); + #[cfg(feature = "native")] + { + use crate::ffi::measure::{POINT_INSIDE, POINT_ON_BOUNDARY, POINT_OUTSIDE, classify_point as cpp_classify}; + let theirs = cpp_classify(cpp(name).cpp().unwrap(), p).unwrap(); + let want_cpp = match want { + Inside => POINT_INSIDE, + Outside => POINT_OUTSIDE, + OnBoundary => POINT_ON_BOUNDARY, + }; + assert_eq!(theirs, want_cpp, "C++ {name} {p:?}"); + } + } + assert_eq!(classify_point(&bx, [f64::NAN, 0.0, 0.0]).unwrap_err(), crate::Error::Argument); + assert_eq!(classify_point(&bx, [0.0, f64::INFINITY, 0.0]).unwrap_err(), crate::Error::Argument); +} + +/// A sheet hung on a solid (fork 9767916's capi test): a 10 mm cube and a 10 x 10 sheet on from its +/// top front edge, merged into one body. Its area is every face's, the cube's 600 and the sheet's +/// 100, not the cube's boundary alone; its volume the cube's. The same on the C++ kernel. +#[test] +fn a_sheet_hung_on_a_solid_counts_in_the_area() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + use solidcore::boolean::api_brep::boolean_merge; + use solidcore::geom::vector3d::Vec3; + use solidcore::primitive::api_primitives::{create_box, create_plane}; + let both = super::run("cube and flap", |k| { + let cube = create_box(k, Vec3::new(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?; + let flap = create_plane(k, Vec3::new(10.0, 0.0, 10.0), 10.0, 10.0)?; + boolean_merge(k, cube, flap) + }) + .map(Body::new) + .unwrap(); + let m = mass_properties(&both, 1e-8).unwrap(); + assert!((m.area - 700.0).abs() < 1e-6 && (m.volume - 1000.0).abs() < 1e-6, "{m:?}"); + #[cfg(feature = "native")] + { + let cpp = crate::Brep::made_core(both); + let c = crate::ffi::measure::mass_properties(cpp.cpp().unwrap(), 1e-8).unwrap(); + assert!((c[0] - m.area).abs() < 1e-6 && (c[1] - m.volume).abs() < 1e-6, "C++ {c:?} vs {m:?}"); + } +} diff --git a/crates/smlib/src/core/merge.rs b/crates/smlib/src/core/merge.rs new file mode 100644 index 0000000000..0590df93b4 --- /dev/null +++ b/crates/smlib/src/core/merge.rs @@ -0,0 +1,178 @@ +//! Sewing and merging on solidcore: the C API's `smlib_stitch` (the fork's +//! `capi/src/smlib_sheets.cpp`) and `smlib_merge_faces` (`capi/src/smlib_ops.cpp`, with +//! `capi/src/smlib_pick.h`'s `pick_edges`). +//! +//! As there, each works on a copy of its body (`SmApiBrepCopy`). A stitch is `SmBrep::StitchFaces` +//! at the caller's tolerance, once, then `SmBrep::OrientTrimmedSurfaces`: outward where the faces +//! close into a solid, else only made to agree across their shared edges. `StitchAndOrient` is not +//! used, as the capi does not use it: it caps the tolerance and stitches a second time, so the +//! caller's tolerance would not be the one that decides. +//! +//! `StitchFaces` glues an edge only to an edge one face uses: a sheet laid on edges two faces of +//! a solid already share (a cover over a box's cut top) is left beside the solid. As the fork's +//! e5d9bba, edges still lamina after that pass are glued at the same tolerance to the edges they +//! lie on whatever uses those (`bAllowSpineEdges`), on a copy ([`close_onto_shared_edges`]). + +use solidcore::kernel::Kernel; +use solidcore::topology::brep::p1 as bl; +use solidcore::topology::brep::p2::{ArenaNotifyState, is_manifold_solid}; +use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face, orient_trimmed_surfaces, with_kernel_callbacks}; +use solidcore::topology::brep::p4::stitch_faces; +use solidcore::topology::brep::p5::remove_topological_edges_and_vertices; +use solidcore::topology::face::p2 as face_p2; +use solidcore::topology::topology_traverser::Marks; +use solidcore::topology::{Brep, FaceId, RegionId, edge}; + +use super::Body; +use super::blend::{edge_miss, finish, on_copy, pick_edges, points_ok, status, tolerance_ok}; +use crate::{Error, Result}; +use solidcore::topology::brep::p3::brep_copy; + +type Out = std::result::Result; + +/// `smlib_stitch`: the faces of a copy of `body` sewn along the vertices and edges that coincide +/// within `tolerance` (finite, positive, at most 1e3), then oriented; with whether the result is a +/// closed solid (no lamina edge left and `IsManifoldSolid`). +pub(crate) fn stitch(body: &Body, tolerance: f64) -> Result<(Body, bool)> { + if !tolerance.is_finite() || !(tolerance > 0.0) || tolerance > 1e3 { + return Err(Error::Argument); + } + let mut closed = false; + let r = on_copy("SmBrep::StitchFaces", body, |k, mut b| { + let mut lamina = match stitch_faces(k, &mut b, tolerance, None, false) { + Ok(o) => o.lamina_edges, + Err(e) => return Ok(Err(status(e))), + }; + if lamina != 0 { + b = match close_onto_shared_edges(k, b, tolerance, &mut lamina)? { + Ok(b) => b, + Err(e) => return Ok(Err(e)), + }; + } + let oriented = with_kernel_callbacks(k, &mut b, |k, b, env| { + let mut marks = Marks::default(); + orient_trimmed_surfaces(k, b, env, &mut marks, true, lamina != 0, None) + }); + if let Err(e) = oriented { + return Ok(Err(status(e))); + } + b.refresh_cached_topology(); + closed = lamina == 0 && is_manifold_solid(&b)?; + Ok(Ok(b)) + })?; + Ok((r, closed)) +} + +/// How many regions of `b` are material: neither the infinite region nor a void (`IsVoid`'s +/// `UNSURE` counts as void, as the capi's `!IsVoid()`). +fn material_regions(b: &Brep) -> solidcore::Result { + let infinite = b.infinite_region(); + let mut n = 0; + for r in bl::regions(b, None)? { + n += usize::from(Some(r) != infinite && b.region(r)?.is_void() == 0); + } + Ok(n) +} + +/// The region on the side of `f`'s faceuse `fu` (its shell's), if any. +fn region_of(b: &Brep, fu: Option) -> solidcore::Result> { + let Some(fu) = fu else { return Ok(None) }; + match b.faceuse(fu)?.shell { + Some(sh) => Ok(b.shell(sh)?.region), + None => Ok(None), + } +} + +/// `remove_faces_inside`: every face of `b` lying between two material regions is inside the +/// part and removed (`DeleteFace(f, TRUE, TRUE)` with the B-rep open for editing): its regions +/// are joined, and its edges and vertices nothing else uses go with it. +fn remove_faces_inside(k: &mut Kernel, b: &mut Brep) -> solidcore::Result> { + let infinite = b.infinite_region(); + let mut inside: Vec = Vec::new(); + for f in bl::faces(b, None)? { + let (u1, u2) = face_p2::faceuses(b, f)?; + let (Some(r1), Some(r2)) = (region_of(b, u1)?, region_of(b, u2)?) else { continue }; + let material = |r: RegionId| -> solidcore::Result { Ok(Some(r) != infinite && b.region(r)?.is_void() == 0) }; + if r1 != r2 && material(r1)? && material(r2)? { + inside.push(f); + } + } + let editing = b.editing_enabled(); + b.set_editing_enabled(true); + let mut deleted = Ok(()); + { + let mut state = ArenaNotifyState::new(b); + let mut env = state.env(None); + let mut marks = Marks::default(); + for f in inside { + let options = DeleteFaceOptions { delete_edges_vertices: true, do_region_nesting: true, ..DeleteFaceOptions::default() }; + if let Err(e) = delete_face(k, b, &mut env, &mut marks, f, options) { + deleted = Err(e); + break; + } + } + } + b.set_editing_enabled(editing); + b.refresh_cached_topology(); + Ok(deleted.map_err(status)) +} + +/// `close_onto_shared_edges` (fork e5d9bba): `b`, stitched as a manifold with `lamina` edges +/// left free, those glued at `tolerance` to the edges they lie on whatever uses those +/// (`StitchFaces` with `bAllowSpineEdges`) on a copy. The copy is taken, with its count of +/// lamina edges, when the gluing closed a space (a new, material region) and, the faces then +/// between two material regions removed, no edge has more than two faces; a sheet that closes +/// nothing with the solid's faces stays as the manifold stitch left it. A gluing that leaves +/// fewer material regions than it found (a sheet with no sector between the faces at its rim: +/// glued elsewhere it joins the solid's inside to the outside) is refused, as the capi's +/// `SMLIB_ERR_UNSUPPORTED`. +fn close_onto_shared_edges(k: &mut Kernel, b: Brep, tolerance: f64, lamina: &mut u32) -> solidcore::Result> { + let mut c = brep_copy(k, &b)?; + let material = material_regions(&c)?; + let outcome = match stitch_faces(k, &mut c, tolerance, None, true) { + Ok(o) => o, + Err(e) => return Ok(Err(status(e))), + }; + if outcome.stitched_edges == 0 { + return Ok(Ok(b)); + } + if material_regions(&c)? < material { + return Ok(Err(Error::Unsupported)); + } + if let Err(e) = remove_faces_inside(k, &mut c)? { + return Ok(Err(e)); + } + for e in bl::edges(&c, None)? { + if edge::is_spine(&c, e)? { + return Ok(Ok(b)); + } + } + *lamina = outcome.lamina_edges; + Ok(Ok(c)) +} + +/// `smlib_merge_faces`: `SmBrep::RemoveTopologicalEdgesAndVertices` on a copy of `body`, keeping +/// the edges nearest `keep` (each within `tolerance`); then `finish` (faces left, and a solid in +/// gives a manifold solid). +pub(crate) fn merge_faces(body: &Body, keep: &[[f64; 3]], tolerance: f64) -> Result { + if !points_ok(keep, true) || !tolerance_ok(tolerance) { + return Err(Error::Argument); + } + on_copy("SmBrep::RemoveTopologicalEdgesAndVertices", body, |k, mut b| { + let kept = if keep.is_empty() { + Vec::new() + } else { + match pick_edges(k, &b, keep, tolerance)? { + Ok((edges, _)) => edges, + Err(m) => return Ok(Err(edge_miss(m))), + } + }; + let solid_in = is_manifold_solid(&b)?; + let keep_edges = (!kept.is_empty()).then_some(kept.as_slice()); + if let Err(e) = remove_topological_edges_and_vertices(k, &mut b, keep_edges, None) { + return Ok(Err(status(e))); + } + b.refresh_cached_topology(); + finish(b, solid_in) + }) +} diff --git a/crates/smlib/src/core/merge_tests.rs b/crates/smlib/src/core/merge_tests.rs new file mode 100644 index 0000000000..25f0f8c003 --- /dev/null +++ b/crates/smlib/src/core/merge_tests.rs @@ -0,0 +1,457 @@ +//! Stitch and merge faces on solidcore, against closed forms: counts, volumes (or areas) and +//! whether the result closes, and every result exports to a record that validates. With SMLib +//! built, the same input converted to the C++ kernel gives the same result or the same error +//! (parity only: the expected values are the analytic ones). +//! +//! A stitch's input is six (or five) separate planar faces of a 10 mm cube, each its own face of +//! one body with its own edges, none shared; a merge's is a cube with four faces cut across, or +//! two boxes unioned, on solidcore. + +use std::collections::BTreeMap; + +use solidcore::curve::curve::Curve; +use solidcore::curve::line::Line; +use solidcore::geom::extent2d::Extent2d; +use solidcore::geom::vector2d::Vec2; +use solidcore::geom::vector3d::Vec3; +use solidcore::kernel::Kernel; +use solidcore::nurbs::b_spline_curve::p1::KnotType; +use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData}; +use solidcore::primitive::api_primitives::{create_box, create_plane}; +use solidcore::primitive::api_trim_ops::trim_surface_with_3d_curves; +use solidcore::surface::plane::p1::Plane; +use solidcore::surface::surface::Surface; +use solidcore::topology::Brep; +use solidcore::topology::brep::p2::ArenaNotifyState; +use solidcore::topology::brep::p3::{merge_brep, new_brep}; +use solidcore::topology::brep::p8::create_face_from_surface; + +use super::merge::{merge_faces, stitch}; +use super::sweep::build_faces; +use super::{Body, area, bounds, counts, make_box, run, to_arrays, volume}; +use crate::export::native; +use crate::{BooleanOp, Error}; + +const TOL: f64 = 0.01; + +/// The sides of the cube [0, 10]^3 as (origin, u axis, v axis), each over [0, 10]^2. +const SIDES: [([f64; 3], [f64; 3], [f64; 3]); 6] = [ + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + ([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), + ([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]), + ([0.0, 10.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + ([0.0, 0.0, 10.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), +]; + +fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +/// The sides `sides` of the cube as separate faces of one body, the top lifted by `lift`. +fn loose_faces(sides: &[usize], lift: f64) -> Body { + run("loose faces", |k| { + let mut b = new_brep(k, solidcore::geom::tol::USE_DEFAULT, solidcore::geom::tol::USE_DEFAULT)?; + for &i in sides { + let Some(&(mut o, u, v)) = SIDES.get(i) else { continue }; + if i == 5 { + o[2] += lift; + } + let plane = Plane::new(v3(o), v3(u), v3(v), Vec2::new(1.0, 1.0), &Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0)); + let domain = Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0); + build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(Surface::from(plane)), &domain, fenv))?; + } + b.refresh_cached_topology(); + Ok(b) + }) + .map(Body::new) + .unwrap() +} + +/// Two 10 mm cubes side by side along x, unioned: one 20 x 10 x 10 block. +fn two_boxes() -> Body { + let a = make_box([0.0; 3], [10.0; 3]).unwrap(); + let b = make_box([10.0, 0.0, 0.0], [10.0; 3]).unwrap(); + super::boolean::boolean(&a, &b, BooleanOp::Union).unwrap().unwrap() +} + +/// The cube with its four faces across x = 5 cut there (`split_faces`): ten faces. +fn split_cube() -> Body { + let plane = crate::ssi::Spec::Plane { origin: [5.0, 0.0, 0.0], xaxis: [0.0, 1.0, 0.0], yaxis: [0.0, 0.0, 1.0], u: [-1.0, 11.0], v: [-1.0, 11.0] }; + let cube = make_box([0.0; 3], [10.0; 3]).unwrap(); + super::split::split_faces(&cube, &[0, 1, 2, 3, 4, 5], &[&plane]).unwrap().unwrap().0 +} + +#[derive(Clone, Copy, Debug)] +enum Input { + SplitCube, + /// [`loose_faces`]: which sides, the top's lift. + Loose(&'static [usize], f64), + TwoBoxes, + Box, +} + +impl Input { + fn core(self) -> Body { + match self { + Input::Loose(sides, lift) => loose_faces(sides, lift), + Input::TwoBoxes => two_boxes(), + Input::SplitCube => split_cube(), + Input::Box => make_box([0.0; 3], [10.0; 3]).unwrap(), + } + } +} + +#[derive(Clone, Copy, Debug)] +enum Op { + Stitch(Input, f64), + Merge(Input, &'static [[f64; 3]], f64), +} + +#[derive(Clone, Debug, PartialEq)] +enum Expect { + /// A solid: volume, counts, closed. + Solid(f64, [usize; 3], bool), + /// A sheet: area, counts, closed. + Sheet(f64, [usize; 3], bool), + Fails(Error), +} + +const ALL: &[usize] = &[0, 1, 2, 3, 4, 5]; +const OPEN: &[usize] = &[0, 1, 2, 3, 4]; + +fn case(name: &str) -> (Op, Expect) { + match name { + "stitch_six_faces_into_a_cube" => (Op::Stitch(Input::Loose(ALL, 0.0), TOL), Expect::Solid(1000.0, [6, 12, 8], true)), + "stitch_five_faces_into_an_open_box" => (Op::Stitch(Input::Loose(OPEN, 0.0), TOL), Expect::Sheet(500.0, [5, 12, 8], false)), + // The top 0.05 mm above the walls: farther than the tolerance, so it stays apart. + "stitch_leaves_a_gap_wider_than_the_tolerance" => (Op::Stitch(Input::Loose(ALL, 0.05), TOL), Expect::Sheet(600.0, [6, 16, 12], false)), + // Within a 0.1 mm tolerance, the same gap closes. + // (the top stays on its own plane, so the solid reaches up to it) + "stitch_closes_a_gap_within_the_tolerance" => (Op::Stitch(Input::Loose(ALL, 0.05), 0.1), Expect::Solid(1005.0, [6, 12, 8], true)), + "stitch_a_cube_already_sewn" => (Op::Stitch(Input::Box, TOL), Expect::Solid(1000.0, [6, 12, 8], true)), + "merge_two_boxes_into_one_block" => (Op::Merge(Input::TwoBoxes, &[], TOL), Expect::Solid(2000.0, [6, 12, 8], false)), + "merge_a_split_cube_into_one" => (Op::Merge(Input::SplitCube, &[], TOL), Expect::Solid(1000.0, [6, 12, 8], false)), + "merge_a_box_changes_nothing" => (Op::Merge(Input::Box, &[], TOL), Expect::Solid(1000.0, [6, 12, 8], false)), + "merge_keeps_a_picked_edge" => (Op::Merge(Input::SplitCube, &[[5.0, 5.0, 10.0]], TOL), Expect::Solid(1000.0, [7, 15, 10], false)), + "merge_picks_near_nothing" => { + (Op::Merge(Input::SplitCube, &[[5.0, 5.0, 10.0], [50.0, 50.0, 50.0]], TOL), Expect::Fails(Error::NoMatch { point: 1 })) + } + _ => panic!("no case {name}"), + } +} + +#[derive(Debug, PartialEq)] +struct Measured { + size: f64, + counts: [usize; 3], + closed: bool, + bounds: ([f64; 3], [f64; 3]), + kinds: BTreeMap, +} + +type Got = std::result::Result; + +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 rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +fn judge(name: &str, who: &str, got: Got) -> Got { + let (op, expect) = case(name); + match (&expect, &got) { + (Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"), + (Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"), + (_, Err(g)) => panic!("{name}: {who} failed: {g}"), + (Expect::Solid(s, n, closed) | Expect::Sheet(s, n, closed), Ok(m)) => { + assert!(rel(m.size, *s) < 1e-9, "{name}: {who} size {} vs analytic {s}", m.size); + assert_eq!(m.counts, *n, "{name}: {who} counts"); + if matches!(op, Op::Stitch(..)) { + assert_eq!(m.closed, *closed, "{name}: {who} closed"); + } + } + } + got +} + +fn is_sheet(name: &str) -> bool { + matches!(case(name).1, Expect::Sheet(..)) +} + +fn solidcore_case(name: &str) -> Got { + let (op, _) = case(name); + let r = match op { + Op::Stitch(input, tol) => stitch(&input.core(), tol), + Op::Merge(input, keep, tol) => merge_faces(&input.core(), keep, tol).map(|b| (b, false)), + }; + let got = r.map(|(b, closed)| { + let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); + let size = if is_sheet(name) { area(&b, 1e-8) } else { volume(&b, 1e-8) }.unwrap(); + Measured { size, counts: counts(&b).unwrap(), closed, bounds: bounds(&b).unwrap(), kinds: kinds(&rec) } + }); + judge(name, "solidcore", got) +} + +/// The case on the C++ kernel, called directly (whatever the choice), on the input converted +/// from solidcore's. +#[cfg(feature = "native")] +fn cpp_case(name: &str) -> Got { + use crate::ops::pick_error; + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let (op, _) = case(name); + let r = match op { + Op::Stitch(input, tol) => ffi::sheets::stitch(Brep::made_core(input.core()).cpp().unwrap(), tol).map_err(error), + Op::Merge(input, keep, tol) => { + ffi::ops::merge_faces(Brep::made_core(input.core()).cpp().unwrap(), keep, tol).map(|h| (h, false)).map_err(pick_error) + } + }; + let got = r.map(|(h, closed)| { + let size = if is_sheet(name) { ffi::measure::area(&h, 1e-8) } else { ffi::volume(&h, 1e-8) }.unwrap(); + let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap()); + let bx = ffi::measure::bounds(&h).unwrap(); + let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + Measured { size, counts: n, closed, bounds: bx, kinds: kinds(&rec) } + }); + judge(name, "C++", got) +} + +#[cfg(feature = "native")] +fn parity(name: &str) { + match (solidcore_case(name), cpp_case(name)) { + (Ok(a), Ok(b)) => { + assert!(rel(a.size, b.size) < 1e-9, "{name}: solidcore {} vs C++ {}", a.size, b.size); + assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)"); + assert_eq!(a.closed, b.closed, "{name}: closed (solidcore vs C++)"); + assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)"); + let near = |x: [f64; 3], y: [f64; 3]| x.iter().zip(&y).all(|(p, q)| (p - q).abs() < 1e-6); + assert!(near(a.bounds.0, b.bounds.0) && near(a.bounds.1, b.bounds.1), "{name}: bounds {:?} vs C++ {:?}", a.bounds, b.bounds); + } + (a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"), + } +} + +macro_rules! cases { + ($($m:ident $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::solidcore_case(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::cpp_case(stringify!($m)); + } + } + )*}; +} + +cases!( + stitch_six_faces_into_a_cube, + stitch_five_faces_into_an_open_box, + stitch_leaves_a_gap_wider_than_the_tolerance, + stitch_closes_a_gap_within_the_tolerance, + stitch_a_cube_already_sewn, + merge_two_boxes_into_one_block, + merge_a_split_cube_into_one, + merge_a_box_changes_nothing, + merge_keeps_a_picked_edge, + merge_picks_near_nothing, +); + +/// The capi's argument checks, before any kernel call. +#[test] +fn hostile_arguments_are_refused() { + let b = make_box([0.0; 3], [10.0; 3]).unwrap(); + for t in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e3 + 1.0] { + assert_eq!(stitch(&b, t).err(), Some(Error::Argument), "stitch tolerance {t}"); + } + for t in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e6] { + assert_eq!(merge_faces(&b, &[], t).err(), Some(Error::Argument), "merge tolerance {t}"); + } + assert_eq!(merge_faces(&b, &[[f64::NAN, 0.0, 0.0]], TOL).err(), Some(Error::Argument)); + let many = vec![[0.0; 3]; crate::MAX_POINTS + 1]; + assert_eq!(merge_faces(&b, &many, TOL).err(), Some(Error::Argument)); +} + +/// A 10 mm cube and, unsewn in the same body (`MergeBrep`), a sheet made on the kernel. +fn cube_and_sheet(sheet: impl FnOnce(&mut Kernel) -> solidcore::Result) -> Body { + run("cube and sheet", |k| { + let mut cube = create_box(k, Vec3::new(0.0, 0.0, 0.0), 10.0, 10.0, 10.0)?; + let sheet = sheet(k)?; + let mut state = ArenaNotifyState::new(&cube); + let mut env = state.env(None); + merge_brep(k, &mut env, &mut cube, &sheet, &solidcore::topology::brep::p2::copy_source_ref, None, None, true)?; + cube.refresh_cached_topology(); + Ok(cube) + }) + .map(Body::new) + .unwrap() +} + +/// The capi's `cube_and_cover` (capi/tests/sheets.cpp): the cube and a cover over its top, a +/// height field z(x, y) over [0, 10]^2 of `degree` and uniform `spans`, its control points at +/// their Greville abscissae (so its parameters are x and y), z = 10 but for the middle one, +/// 10 + `rise`, trimmed to the square by lines (`SmApiTrimSurfaceWith3dCurves`), so its rim is the +/// cube's top edges. +fn cube_and_cover(degree: usize, spans: usize, rise: f64) -> Body { + cube_and_sheet(|k| { + let n = spans + degree; + let step = 10.0 / spans as f64; + let unique: Vec = (0..=spans).map(|k| step * k as f64).collect(); + let mult: Vec = (0..=spans).map(|k| if k == 0 || k == spans { degree + 1 } else { 1 }).collect(); + let knots: Vec = unique.iter().zip(&mult).flat_map(|(&u, &m)| std::iter::repeat_n(u, m)).collect(); + let greville: Vec = (0..n).map(|i| knots[i + 1..=i + degree].iter().sum::() / degree as f64).collect(); + let mut net = Vec::with_capacity(n * n); + for i in 0..n { + for j in 0..n { + let z = if 2 * i + 1 == n && 2 * j + 1 == n { 10.0 + rise } else { 10.0 }; + net.push(Vec3::new(greville[i], greville[j], z)); + } + } + let surface = BSplineSurface::create_canonical(&CanonicalSurfaceData { + u_degree: degree, + v_degree: degree, + control_points: &net, + form: BSplineSurfaceForm::Unspecified, + u_multiplicities: &mult, + v_multiplicities: &mult, + u_knots: &unique, + v_knots: &unique, + knot_type: KnotType::Unspecified, + weights: None, + uv_domain: None, + })?; + let corner = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]].map(|[x, y]| Vec3::new(x, y, 10.0)); + let mut sides = Vec::with_capacity(4); + for i in 0..4 { + sides.push(Curve::from(Line::create_line_segment(3, corner[i], corner[(i + 1) % 4], None)?)); + } + trim_surface_with_3d_curves(k, Surface::from(surface), &[4], sides) + }) +} + +/// A biquadratic Bezier cover rising 9 at its middle control point, +/// z = 10 + 9 * 2s(1-s) * 2t(1-t) (s = x/10, t = y/10): the capi's cover. +fn cube_and_biquadratic_cover() -> Body { + cube_and_cover(2, 1, 9.0) +} + +/// A bicubic cover over 8 spans whose only raised control point is the middle one: the capi's +/// cover lying on the cube's top for 2.5 mm in from every edge. +fn cube_and_bicubic_cover() -> Body { + cube_and_cover(3, 8, 9.0) +} + +/// The capi's flap: a 10 x 10 sheet on from the cube's top edge at x = 10, in the plane z = 10. +fn cube_and_flap() -> Body { + cube_and_sheet(|k| create_plane(k, Vec3::new(10.0, 0.0, 10.0), 10.0, 10.0)) +} + +/// The space between the cover and the cube's top encloses 9 * (10/3)^2 = 100 mm^3: sewn, it is +/// part of the solid and the top, now inside it, goes. A closed solid of the cube's five other +/// faces and the cover, 1100 mm^3. +#[test] +fn a_cover_sews_onto_a_cube_into_a_closed_solid() { + let (sewn, closed) = stitch(&cube_and_biquadratic_cover(), 1e-6).unwrap(); + assert!(closed); + assert_eq!(counts(&sewn).unwrap()[0], 6); + let v = volume(&sewn, 1e-8).unwrap(); + assert!((v - 1100.0).abs() < 1e-6, "volume {v}"); +} + +/// The bicubic cover overlaps the top: no sector between the faces at the rim to sew it into, so +/// it is refused, not sewn so that the cube's inside joins the outside. +#[test] +fn a_cover_lying_on_the_cube_is_refused() { + assert_eq!(stitch(&cube_and_bicubic_cover(), 1e-6).err(), Some(Error::Unsupported)); +} + +/// The flap closes nothing with the cube: it stays the sheet beside it the manifold stitch +/// leaves, not glued into an edge three faces would share. +#[test] +fn a_flap_on_a_cube_edge_stays_beside_it() { + let (sewn, closed) = stitch(&cube_and_flap(), 1e-6).unwrap(); + assert!(!closed); + assert_eq!(counts(&sewn).unwrap()[0], 7); + let (v, a) = (volume(&sewn, 1e-8).unwrap(), area(&sewn, 1e-8).unwrap()); + assert!((v - 1000.0).abs() < 1e-6 && (a - 700.0).abs() < 1e-6, "volume {v}, area {a}"); +} + +/// The same three on C++, the same input converted: the fork's e5d9bba, as its capi tests say. +#[cfg(feature = "native")] +#[test] +fn sheets_on_a_cube_stitch_the_same_on_cpp() { + let _cpp = crate::route::on_cpp(); + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let sew = |b: Body| ffi::sheets::stitch(Brep::made_core(b).cpp().unwrap(), 1e-6).map_err(error); + let (cover, closed) = sew(cube_and_biquadratic_cover()).unwrap(); + assert!(closed); + assert_eq!(ffi::counts(&cover).unwrap()[0], 6); + assert!((ffi::volume(&cover, 1e-8).unwrap() - 1100.0).abs() < 1e-6); + assert_eq!(sew(cube_and_bicubic_cover()).err(), Some(Error::Unsupported)); + let (flap, closed) = sew(cube_and_flap()).unwrap(); + assert!(!closed); + assert_eq!(ffi::counts(&flap).unwrap()[0], 7); + assert!((ffi::volume(&flap, 1e-8).unwrap() - 1000.0).abs() < 1e-6); +} + +/// The inputs are never changed. +#[test] +fn the_body_stays_as_it_was() { + let loose = loose_faces(ALL, 0.0); + let _ = stitch(&loose, TOL).unwrap(); + assert_eq!(counts(&loose).unwrap(), [6, 24, 24]); + let block = two_boxes(); + let was = counts(&block).unwrap(); + let _ = merge_faces(&block, &[], TOL).unwrap(); + assert_eq!(counts(&block).unwrap(), was); +} + +/// Face and edge tags (`Brep::tagged`) through a stitch and a merge on solidcore, as on +/// C++: the input is tagged on solidcore, converted to C++ and tagged the same way there. +#[cfg(feature = "native")] +mod history { + use crate::{Brep, ffi}; + + #[test] + fn tags_cross_a_stitch() { + let _cpp = crate::route::on_cpp(); + crate::init().unwrap(); + let a = Brep::made_core(super::loose_faces(super::ALL, 0.0)).tagged(0).unwrap(); + let cpp = Brep::made(ffi::sheets::stitch(a.cpp().unwrap(), super::TOL).unwrap().0).history().unwrap(); + let core = Brep::made_core(super::stitch(a.core().unwrap(), super::TOL).unwrap().0).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } + + #[test] + fn tags_cross_a_merge() { + let _cpp = crate::route::on_cpp(); + crate::init().unwrap(); + let a = Brep::made_core(super::split_cube()).tagged(0).unwrap(); + let cpp = Brep::made(ffi::ops::merge_faces(a.cpp().unwrap(), &[], super::TOL).unwrap()).history().unwrap(); + let core = Brep::made_core(super::merge_faces(a.core().unwrap(), &[], super::TOL).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } +} diff --git a/crates/smlib/src/core/offset.rs b/crates/smlib/src/core/offset.rs new file mode 100644 index 0000000000..f07d3bcfa4 --- /dev/null +++ b/crates/smlib/src/core/offset.rs @@ -0,0 +1,660 @@ +//! Shells, face offsets, thickening, sheet offsets and drafts on solidcore: the C API's logic +//! round SMLib's offset executive, ported from the fork's `capi/src/smlib_ops.cpp` +//! (`smlib_shell`, `smlib_offset_faces`, `smlib_thicken`, `smlib_draft`, `leaves_cavity`, +//! `FaceSet`, `outer_faceuse`, `move_faces`, `draft_faces`), `capi/src/smlib_sheets.cpp` +//! (`smlib_offset_sheet`) and `capi/src/smlib_pick.h` (`pick_faces`). +//! +//! As there, every operation works on a copy of its body (`SmApiBrepCopy`) and picks faces by +//! point; the shell and the offsets are `SmApiShellBrepFull` and `SmApiOffsetBrepFull`, with +//! convex edges extended and the results merged. A sheet offset takes faces that close into a +//! solid as the capi does since tangency-labs/smlib#17. +//! +//! A draft tilts planes and, since tangency-labs/smlib#27, turns a cylinder about the pull into +//! a cone (solidcore's `Cone::lean_cylinder`, solidcore#1545), and since smlib#53 a cone about the +//! pull into the cone its generators turned make. A draft's tilted faces go in through solidcore's +//! `SmBrep::LocalOperation` ([`local_operation`]). The capi's check that it moved some face +//! (`moved_faces`) marks the replacement surfaces; solidcore names a surface by its owning face, so +//! the mark goes on the picked faces, which it stays with through the operation (solidcore#1875). + +use solidcore::EntityRef; +use solidcore::SolidError; +use solidcore::geom::attribute::{Attribute, Behavior, ids}; +use solidcore::geom::axis2_placement::Axis2Placement; +use solidcore::geom::object::Shape; +use solidcore::geom::vector3d::{Point3, Vec3}; +use solidcore::intersect::point_class::PointClassType; +use solidcore::kernel::Kernel; +use solidcore::math::Point2; +use solidcore::nurbs::b_spline_curve::ControlPointForm; +use solidcore::nurbs::b_spline_surface::p1::SurfParamType; +use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType; +use solidcore::nurbs::knots::Side; +use solidcore::offset::offset_executive::p2::{OffsetOptions, ShellOptions, local_operation_simple, offset_brep_simple, shell_brep_simple}; +use solidcore::offset::offset_faces::{OffsetFacesExecutive, OffsetFacesRefusal}; +use solidcore::query::api_queries::{face_bounding_box, face_classify_uv, face_closest_point_in_shape, face_internal_point}; +use solidcore::query::brep::{BrepIdentity, BrepQuery, LiveBrepObjects}; +use solidcore::surface::cone::Cone; +use solidcore::surface::surface::{Surface, SurfaceKind}; +use solidcore::topology::brep::{p1 as brep_p1, p2 as brep_p2}; +use solidcore::topology::face::p2 as face_p2; +use solidcore::topology::{Brep, BrepId, FaceId, FaceuseId}; + +use super::blend::{Miss, on_copy, p3, pick, points_ok, status as blend_status, tolerance_ok}; +use super::{Body, answer}; +use crate::{Error, Result}; + +/// The capi's statuses as SolidCraft errors: an operation's own result. +type Out = std::result::Result; + +/// `SM_ERR_INVALID_INPUT`. +const INVALID_INPUT: i32 = 1007; + +/// `SM_ERR_OFFSET_WALLS_TOUCH` (fork a759622), which `status` (smlib_shim.h) reports as +/// `SMLIB_ERR_WALLS_TOUCH`. +const OFFSET_WALLS_TOUCH: i32 = 1042; + +/// `status` (smlib_shim.h): an offset whose walls touch is [`Error::WallsTouch`]; a vertex moved +/// past an edge's other end (`SM_ERR_OFFSET_EDGE_VANISHES`, fork 34318f4) is +/// `SMLIB_ERR_UNSUPPORTED`; any other status as the blends pass it on. +fn status(e: SolidError) -> Error { + match e { + SolidError::Upstream { code: OFFSET_WALLS_TOUCH, .. } => Error::WallsTouch, + e if e.is_offset_edge_vanishes() => Error::Unsupported, + e => blend_status(e), + } +} + +/// `std::fabs(x) > 1e6` and the rest of the capi's size checks: finite, not zero, at most 1e6. +fn distance_ok(x: f64) -> bool { + x.is_finite() && x != 0.0 && x.abs() <= 1e6 +} + +/// The B-rep id every shape and face key in this module names the one body by. +const ONE: BrepId = BrepId::from_index(0); + +/// Faces with their loose bounding boxes, for nearest-face queries (`FaceSet`). +struct FaceSet { + faces: Vec, + boxes: Vec<(Point3, Point3)>, + /// Each face's shape (`SmApiFaceClosestPoint` makes one per query; it depends only on the + /// face, so it is made once). + shapes: Vec, +} + +impl FaceSet { + fn new() -> FaceSet { + FaceSet { faces: Vec::new(), boxes: Vec::new(), shapes: Vec::new() } + } + + /// `add`: false when the face's box cannot be measured. + fn add(&mut self, k: &mut Kernel, b: &Brep, f: FaceId) -> solidcore::Result { + let Some(bx) = answer(face_bounding_box(k, b, f, false))? else { return Ok(false) }; + let Some(shape) = answer(Shape::new(k, &(ONE, b), &[], &[(ONE, f)], &[], &[]))? else { return Ok(false) }; + self.faces.push(f); + self.boxes.push((bx.min(), bx.max())); + self.shapes.push(shape); + Ok(true) + } + + /// `nearest`: the distance from `p` to the nearest face, with the nearest point; infinite + /// when none answers. A face whose box is farther than the nearest face so far is skipped. + fn nearest(&self, k: &Kernel, live: &LiveBrepObjects<'_, '_>, p: Point3) -> solidcore::Result<(f64, Option)> { + let (mut best, mut at) = (f64::INFINITY, None); + for ((&f, (lo, hi)), shape) in self.faces.iter().zip(&self.boxes).zip(&self.shapes) { + let mut bx = 0.0; + for (l, (h, x)) in [lo.x, lo.y, lo.z].into_iter().zip([hi.x, hi.y, hi.z].into_iter().zip([p.x, p.y, p.z])) { + let out = (l - x).max((x - h).max(0.0)); + bx += out * out; + } + if f64::sqrt(bx) >= best { + continue; + } + if let Some(c) = answer(face_closest_point_in_shape(k, shape, live, (ONE, f), p))? + && c.distance.is_finite() + && c.distance < best + { + best = c.distance; + at = Some(c.point); + } + } + Ok((best, at)) + } +} + +/// The live view of `b` the closest-point solves read, under `identity`: `query`'s effective +/// identity ([`BrepQuery::effective_identity`]). +fn live<'a, 'i>(b: &'a mut Brep, identity: &'a BrepIdentity<'i>) -> solidcore::Result> { + LiveBrepObjects::new(b, identity, None) +} + +/// `pick_faces` (`face_distance`: `SmApiFaceClosestPoint`; a face whose query returns a status +/// other than success is skipped, [`answer`]). +pub(super) fn pick_faces(k: &mut Kernel, b: &mut Brep, points: &[[f64; 3]], tol: f64) -> solidcore::Result, Miss>> { + let all = brep_p1::faces(b, None)?; + let model_tol = b.tolerance(); + let mut shapes = Vec::with_capacity(all.len()); + for &f in &all { + shapes.push(answer(Shape::new(k, &(ONE, &*b), &[], &[(ONE, f)], &[], &[]))?); + } + let query = BrepQuery::of_brep(b); + let identity = query.effective_identity(); + let objects = live(b, &identity)?; + let k: &Kernel = k; + let indices: Vec = (0..all.len()).collect(); + let picked = pick(&indices, points, tol, model_tol, |i, p| { + let (Some(&f), Some(Some(shape))) = (all.get(i), shapes.get(i)) else { return Ok(None) }; + Ok(answer(face_closest_point_in_shape(k, shape, &objects, (ONE, f), p))?.map(|c| c.distance)) + })?; + Ok(picked.map(|(p, _)| p.iter().filter_map(|&i| all.get(i).copied()).collect())) +} + +/// A face pick's failure as `pick_error` (ops.rs) reads it. +pub(super) fn face_miss(m: Miss) -> Error { + match m { + Miss::None(point) => Error::NoMatch { point }, + Miss::Ambiguous(point) => Error::Ambiguous { point }, + } +} + +/// `outer_faceuse`: the faceuse of `f` on the outside of its body, the one bounding the infinite +/// region (for a sheet, both do; then the upward one). +fn outer_faceuse(b: &Brep, f: FaceId) -> solidcore::Result> { + let Some(fu) = face_p2::upward_faceuse(b, f)? else { return Ok(None) }; + let use_ = b.faceuse(fu)?; + let outside = match use_.shell { + Some(s) => b.shell(s)?.region.is_some() && b.shell(s)?.region == b.infinite_region(), + None => false, + }; + Ok(if outside { Some(fu) } else { use_.mate }) +} + +/// `leaves_cavity`: whether an inward offset of `wall` (> 0) from every face of the solid `b` +/// but `open` leaves a cavity: a point inside `b` farther than `wall`, and a margin, from every +/// face that is offset. A point counts only as the centre of a ball inside `b`, reached from a +/// face sample by stepping inward along the normal, then from ball to ball. The capi refuses a +/// wall this finds no room for before SMLib sees it (C++ SMLib corrupts memory there, +/// solidcore#380); the same refusal here keeps the C++ path's answer. +fn leaves_cavity(k: &mut Kernel, b: &mut Brep, open: &[FaceId], wall: f64) -> solidcore::Result { + let list = brep_p1::faces(b, None)?; + let (mut all, mut walls) = (FaceSet::new(), FaceSet::new()); + for &f in &list { + if !all.add(k, b, f)? { + return Ok(false); + } + if !open.contains(&f) && !walls.add(k, b, f)? { + return Ok(false); + } + } + if walls.faces.is_empty() { + return Ok(true); + } + let tol = b.tolerance(); + let margin = (10.0 * tol).max(1e-3 * wall); + let need = wall + margin; + // Face samples: a grid over each offset face's domain, the points inside its trims, each + // with the inward unit normal there. + const GRID: usize = 5; + const MAX_SAMPLES: usize = 2048; + let mut samples: Vec<(Point3, Vec3)> = Vec::new(); + for &f in &walls.faces { + let Some(fu) = outer_faceuse(b, f)? else { continue }; + let inward = if b.faceuse(fu)?.orientation == OrientType::Same { -1.0 } else { 1.0 }; + let dom = b.face(f)?.uv_domain; + for i in 0..GRID { + for j in 0..GRID { + if samples.len() >= MAX_SAMPLES { + break; + } + let uv: Point2 = dom.evaluate((i as f64 + 0.5) / GRID as f64, (j as f64 + 0.5) / GRID as f64); + if answer(face_classify_uv(k, b, f, uv))? != Some(PointClassType::Face) { + continue; + } + let Some(surf) = b.face(f)?.surface.as_ref() else { break }; + let (Some(at), Some(n)) = (answer(surf.evaluate_point(k, uv))?, answer(surf.evaluate_normal(k, uv, Side::Left, Side::Left))?) else { + continue; + }; + let len = n.length(); + if !(len > 0.0) || !len.is_finite() { + continue; + } + samples.push((at, n * (inward / len))); + } + } + } + let query = BrepQuery::of_brep(b); + let identity = query.effective_identity(); + let objects = live(b, &identity)?; + let k: &Kernel = k; + // A step of `h` inward from a sample ends at a ball's centre when no face is nearer to it + // than `h` (the step crossed none, and the ball touches only the sample's face). + let ball = |s: Point3, n: Vec3, h: f64| -> solidcore::Result<(bool, Point3, f64)> { + let c = s + n * h; + let (r, _) = all.nearest(k, &objects, c)?; + Ok((r.is_finite() && r >= h * (1.0 - 1e-9), c, r)) + }; + // First the direct step of `need` from each sample. + for &(s, n) in &samples { + let (inside, c, _) = ball(s, n, need)?; + if inside && walls.nearest(k, &objects, c)?.0 >= need { + return Ok(true); + } + } + // Then climbing, from a small ball at each sample: step away from the nearest offset face by + // most of the ball's radius (staying inside the ball, so inside the body) while the distance + // from the offset faces grows. + const STEPS: usize = 48; + for &(s, n) in &samples { + let (inside, mut c, mut r) = ball(s, n, margin.max(1e3 * tol))?; + if !inside { + continue; + } + for _ in 0..STEPS { + let (d, q) = walls.nearest(k, &objects, c)?; + if d >= need { + return Ok(true); + } + let Some(q) = q.filter(|_| d > 0.0 && d.is_finite()) else { break }; + let next = c + (c - q) * (0.9 * r / d); + let (rn, _) = all.nearest(k, &objects, next)?; + if !(rn > 0.0) || !rn.is_finite() || walls.nearest(k, &objects, next)?.0 <= d * (1.0 + 1e-9) { + break; + } + c = next; + r = rn; + } + } + Ok(false) +} + +/// `SmApiShellBrepFull(b, distance, TRUE, TRUE, TRUE, open, r)`: convex edges extended, results +/// merged, side walls made at a sheet's edges, and (as the fork does, 5448b9a) a thickened +/// sheet's coplanar side walls one face. +fn shell_brep_full(k: &mut Kernel, b: &Brep, distance: f64, open: &[FaceId]) -> solidcore::Result { + let options = ShellOptions { + offset: OffsetOptions { distance, extend_convex_edges: true, merge_results: true }, + create_offset_solid: true, + merge_sheet_side_walls: true, + }; + shell_brep_simple(k, b, options, open) +} + +/// `SmApiOffsetBrepFull(b, distance, TRUE, TRUE, r)`. +fn offset_brep_full(k: &mut Kernel, b: &mut Brep, distance: f64) -> solidcore::Result { + offset_brep_simple(k, b, OffsetOptions { distance, extend_convex_edges: true, merge_results: true }) +} + +/// `finish`: the result must have faces, and a solid input must give a closed manifold solid. +fn finish(b: Brep, solid_in: bool) -> solidcore::Result> { + if b.face_list().is_empty() { + return Ok(Err(Error::Empty)); + } + if solid_in && !brep_p2::is_manifold_solid(&b)? { + return Ok(Err(Error::NotSolid)); + } + Ok(Ok(b)) +} + +/// An SMLib call's own status passed on (`status(...)` in the capi), else the value. +fn called(r: solidcore::Result) -> solidcore::Result> { + match r { + Ok(v) => Ok(Ok(v)), + Err(e) => Ok(Err(status(e))), + } +} + +/// `smlib_shell`: the faces nearest `points` (each within `tolerance`) opened, the rest offset +/// by `thickness` (negative: inward) into a wall; none gives a closed hollow body. An inward +/// wall of a solid that leaves no cavity is [`Error::NoRoom`], refused before the shell. +pub(crate) fn shell(body: &Body, points: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result { + if !points_ok(points, true) || !tolerance_ok(tolerance) || !distance_ok(thickness) { + return Err(Error::Argument); + } + on_copy("smlib_shell", body, |k, mut w| { + let picked = match pick_faces(k, &mut w, points, tolerance)? { + Ok(p) => p, + Err(m) => return Ok(Err(face_miss(m))), + }; + let solid_in = brep_p2::is_manifold_solid(&w)?; + if thickness < 0.0 && solid_in && !leaves_cavity(k, &mut w, &picked, -thickness)? { + return Ok(Err(Error::NoRoom)); + } + match called(shell_brep_full(k, &w, thickness, &picked))? { + Ok(r) => finish(r, solid_in), + Err(e) => Ok(Err(e)), + } + }) +} + +/// `smlib_offset_faces`: its checks, the pick, the solid check, (moving faces in) the cavity +/// check, then `move_faces`. +pub(crate) fn offset_faces(body: &Body, points: &[[f64; 3]], tolerance: f64, distance: f64) -> Result { + if !points_ok(points, false) || !tolerance_ok(tolerance) || !distance_ok(distance) { + return Err(Error::Argument); + } + on_copy("smlib_offset_faces", body, |k, mut w| { + let picked = match pick_faces(k, &mut w, points, tolerance)? { + Ok(p) => p, + Err(m) => return Ok(Err(face_miss(m))), + }; + if !brep_p2::is_manifold_solid(&w)? { + return Ok(Err(Error::Argument)); + } + // Faces moved in so far that no material is left behind them: the body must reach + // deeper than the move behind the moved faces. + if distance < 0.0 { + let others: Vec = brep_p1::faces(&w, None)?.into_iter().filter(|f| !picked.contains(f)).collect(); + if !leaves_cavity(k, &mut w, &others, -distance)? { + return Ok(Err(Error::NoRoom)); + } + } + match move_faces(k, &mut w, &picked, distance) { + Ok(()) => finish(w, true), + Err(e) => Ok(Err(e)), + } + }) +} + +/// `move_faces`: moves the picked faces of `b` (in place) `distance` along their outward normals: +/// each gets its surface's offset, and the local operation extends or trims the faces around them +/// to meet it; the tangent blends on them follow (`SmOffsetFacesExecutive`: taken off, the faces +/// moved, put back at their own sizes). A blend that no longer fits is [`Error::NoRoom`], one that +/// cannot be put back as it was [`Error::Unsupported`], one that would need a continuation that +/// cannot be made exactly [`Error::NotExact`], a move that moved nothing [`Error::NoEffect`]. +fn move_faces(k: &mut Kernel, b: &mut Brep, picked: &[FaceId], distance: f64) -> Out<()> { + let mut exec = OffsetFacesExecutive::new(); + exec.offset_faces(k, b, picked, distance).map_err(|e| match exec.refusal() { + OffsetFacesRefusal::NoRoom => Error::NoRoom, + OffsetFacesRefusal::Unsupported => Error::Unsupported, + OffsetFacesRefusal::NotExact => Error::NotExact, + OffsetFacesRefusal::NoEffect => Error::NoEffect, + // Not in the fork (solidcore#2020, #2027): the blends cannot be put back, the fillet's + // status kept. SolidCraft has no such error: a move solidcore does not make. + OffsetFacesRefusal::NotPutBack => Error::Unsupported, + OffsetFacesRefusal::None => status(e), + }) +} + +/// `smlib_thicken`: a sheet thickened by `thickness` along its normals, or (symmetric) moved back +/// by half first; a closed solid is [`Error::Argument`]. +pub(crate) fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result { + if !distance_ok(thickness) { + return Err(Error::Argument); + } + on_copy("smlib_thicken", body, |k, mut w| { + if brep_p2::is_manifold_solid(&w)? { + return Ok(Err(Error::Argument)); + } + if symmetric { + w = match called(offset_brep_full(k, &mut w, -0.5 * thickness))? { + Ok(moved) => moved, + Err(e) => return Ok(Err(e)), + }; + } + match called(shell_brep_full(k, &w, thickness, &[]))? { + Ok(r) => finish(r, true), + Err(e) => Ok(Err(e)), + } + }) +} + +/// `smlib_offset_sheet`: every face of a sheet moved onto its surface's offset by `distance` +/// along its normal; faces that close into a solid (a sphere's one face, a block's six) give the +/// offset solid (tangency-labs/smlib#17, which dropped the capi's refusal of a closed solid). +pub(crate) fn offset_sheet(body: &Body, distance: f64) -> Result { + if !distance_ok(distance) { + return Err(Error::Argument); + } + on_copy("smlib_offset_sheet", body, |k, mut w| { + // `take`: a result without faces (a sphere moved in to its centre) is SMLIB_ERR_EMPTY. + match called(offset_brep_full(k, &mut w, distance))? { + Ok(r) => finish(r, false), + Err(e) => Ok(Err(e)), + } + }) +} + +/// A unit vector from `v`, as `smlib_draft`'s `unit`; `None` for a vector too short or not +/// finite. +fn unit(v: [f64; 3]) -> Option { + if !v.iter().all(|x| x.is_finite()) { + return None; + } + let u = p3(v); + let len = u.length(); + (len > 1e-12 && len.is_finite()).then(|| u * (1.0 / len)) +} + +/// `outward_normal`: the outward normal of face `f` (its outer faceuse `fu`) at `at`, a point on +/// it: its surface's normal there, turned with the outer faceuse. `None` where SMLib cannot place +/// the point on the surface (the capi's `STEPInversionAnswered` also takes a point outside the +/// domain or off the surface by more than its tolerance; solidcore's inversion answers those with +/// a status, so they are `None` here: the face's own internal point is neither). +fn outward_normal(k: &Kernel, b: &Brep, f: FaceId, fu: FaceuseId, at: Point3) -> solidcore::Result> { + let Some(surf) = b.face(f)?.surface.as_ref() else { return Ok(None) }; + let Some((step, _)) = answer(surf.step_inversion(k, &surf.step_uv_domain(), at, 1e6, None))? else { return Ok(None) }; + let Some(nurb) = answer(surf.convert_uv_from_step_to_nurbs(k, step))? else { return Ok(None) }; + let Some(mut n) = answer(surf.evaluate_normal(k, nurb, Side::Left, Side::Left))? else { return Ok(None) }; + if b.faceuse(fu)?.orientation != OrientType::Same { + n = -n; + } + Ok(Some(n)) +} + +/// `drafted_cone` (tangency-labs/smlib#27, #53): the cone a face on the cone or cylinder `cyl` about +/// the pull (its outward normal `n` at `inside`) becomes drafted by `angle` about its circle on the +/// neutral plane (through `origin`, normal `normal`), so that it leans in going along `pull`: its +/// generators turned by `angle` in their planes through the axis, about that circle. With R0 the +/// radius on the neutral plane and t the slope of the radius going along the pull (0 for a +/// cylinder), the radius h along the pull from that circle is R0 + h tan(atan(t) - s angle), s = 1 +/// where the face looks away from the axis (a pin), -1 where it looks towards it (a hole). It is +/// the surface's own NURB with each control point moved radially by the new radius over the old at +/// its height, so it keeps the surface's parameters and the face's UV trims stay valid: for a +/// cylinder solidcore's `Cone::lean_cylinder` (solidcore#1545), for a cone the same edit on +/// [`Cone::base_mut`]. [`Error::Unsupported`] when the pull does not run along the axis, the axis +/// lies in the neutral plane, or the cone would close within the face's span. +#[allow(clippy::too_many_arguments)] // the capi's drafted_cone +fn drafted_cone( + k: &Kernel, + cyl: &Cone, + n: Vec3, + inside: Point3, + origin: Point3, + normal: Vec3, + pull: Vec3, + angle: f64, +) -> solidcore::Result> { + let frame = cyl.canonical().origin; + let z = frame.x_axis().cross(frame.y_axis()); + let len = z.length(); + if !(len > 0.0 && len.is_finite()) { + return Ok(Err(Error::Unsupported)); + } + let z = z * (1.0 / len); + let sense = z.dot(pull); + if (sense.abs() - 1.0).abs() > 1e-9 { + return Ok(Err(Error::Unsupported)); + } + let axis = frame.origin(); + let toward = z.dot(normal); + if toward.abs() < 1e-12 { + return Ok(Err(Error::Unsupported)); + } + // The hinge circle's centre: the axis on the neutral plane, at the cone's height v0. + let v0 = (origin - axis).dot(normal) / toward; + let centre = axis + z * v0; + let radial = (inside - centre) - pull * (inside - centre).dot(pull); + let s = if radial.dot(n) >= 0.0 { 1.0 } else { -1.0 }; + // The cone's radius is linear in its height along z; along the pull it changes by `slope` per + // unit, and drafted by `turned`. + let r0 = cyl.radius(v0); + let slope = (cyl.radius(1.0) - cyl.radius(0.0)) * sense; + let turned = (slope.atan() - s * angle).tan(); + if !(r0 > 0.0) || !turned.is_finite() { + return Ok(Err(Error::Unsupported)); + } + if cyl.is_cylinder() { + return Ok(answer(cyl.lean_cylinder(k, turned * sense, v0))?.ok_or(Error::Unsupported)); + } + let mut cone = cyl.clone(); + let net = cone.base_mut(); + let (nu, nv) = (net.number_control_points(SurfParamType::U), net.number_control_points(SurfParamType::V)); + for i in 0..nu { + for j in 0..nv { + let Some((p, w)) = answer(net.control_point(ControlPointForm::EuclideanRational, i, j))? else { + return Ok(Err(Error::Unsupported)); + }; + let h = (p - centre).dot(pull); + let (was, now) = (cyl.radius(v0 + h * sense), r0 + h * turned); + if !(was > 0.0 && now > 0.0) { + return Ok(Err(Error::Unsupported)); + } + let foot = centre + pull * h; + if answer(net.set_control_point(ControlPointForm::EuclideanRational, i, j, foot + (p - foot) * (now / was), w))?.is_none() { + return Ok(Err(Error::Unsupported)); + } + } + } + // `CopyAndAddAnalytics`: the cone's analytic data from its edited NURB. + if answer(cone.rebuild_step_from_nurb_parameters(k))?.is_none() { + return Ok(Err(Error::Unsupported)); + } + Ok(Ok(cone)) +} + +/// The capi's `Before` mark on what `SmBrep::LocalOperation` is to move: above the history's tag. +const MOVED_MARK: u64 = ids::USER_1 + 8; + +/// `Before::mark` on each replacement surface. A surface has no identity apart from its owning +/// face here, and `faces[i]`'s offset face takes `surfaces[i]`, so the mark goes on the picked face +/// and travels as the surface's does (Copy behaviour; solidcore#1875). The one difference, recorded +/// in solidcore's `topology.brep_local_operation--1875-face-mark-for-surface-mark` finding: a merge +/// keeping an unpicked face over a picked one hands that survivor the mark, where SMLib's survivor +/// keeps its own unmarked surface. +pub(super) fn mark_moved(w: &mut Brep, picked: &[FaceId]) -> solidcore::Result<()> { + for &f in picked { + w.add_attribute(EntityRef::Face(f), Attribute::long(MOVED_MARK, 0, Behavior::Copy))?; + } + Ok(()) +} + +/// `moved_faces`'s reading (`read_marks`): whether any face of `w` carries the mark, every mark +/// removed. +pub(super) fn moved_faces(w: &mut Brep) -> solidcore::Result { + let mut moved = false; + for f in w.face_list().to_vec() { + let e = EntityRef::Face(f); + let marks: Vec<_> = w.entity_attributes(e).into_iter().filter(|(_, a)| a.attribute_id() == MOVED_MARK).map(|(h, _)| h).collect(); + moved = moved || !marks.is_empty(); + for h in marks { + w.remove_attribute(e, h)?; + } + } + Ok(moved) +} + +/// `smlib_draft`: its checks, the solid check, the pick, then `draft_faces`: each picked face (a +/// plane parallel to the pull) gets a copy of its plane turned by `angle` about its line on the +/// neutral plane, and a cylinder about the pull the cone `drafted_cone` gives +/// (tangency-labs/smlib#27); `SmBrep::LocalOperation` extends or trims the faces around them to +/// meet the tilted surfaces. +pub(crate) fn draft( + body: &Body, + points: &[[f64; 3]], + tolerance: f64, + neutral_origin: [f64; 3], + neutral_normal: [f64; 3], + pull: [f64; 3], + angle: f64, +) -> Result { + let (Some(normal), Some(along)) = (unit(neutral_normal), unit(pull)) else { return Err(Error::Argument) }; + if !points_ok(points, false) + || !tolerance_ok(tolerance) + || !neutral_origin.iter().all(|x| x.is_finite() && x.abs() <= 1e9) + || !angle.is_finite() + || angle == 0.0 + || angle.abs() >= 1.5697963267948966 + { + return Err(Error::Argument); + } + on_copy("smlib_draft", body, |k, mut w| { + if !brep_p2::is_manifold_solid(&w)? { + return Ok(Err(Error::Argument)); + } + let picked = match pick_faces(k, &mut w, points, tolerance)? { + Ok(p) => p, + Err(m) => return Ok(Err(face_miss(m))), + }; + // `draft_faces`. + let mut surfaces = Vec::with_capacity(picked.len()); + for &f in &picked { + let fu = outer_faceuse(&w, f)?; + let face = w.face(f)?; + let (Some(surf), Some(fu)) = (face.surface.as_ref(), fu) else { + return Ok(Err(Error::Status(INVALID_INPUT))); + }; + // A cylinder about the pull drafted becomes a cone (`drafted_cone`). + if let SurfaceKind::Cone(c) = surf.kind_with_nurb(k)? { + let cyl = (**c).clone(); + let Some(inside) = answer(face_internal_point(k, &mut w, f))? else { + return Ok(Err(Error::Status(INVALID_INPUT))); + }; + let Some(n) = outward_normal(k, &w, f, fu, inside)? else { + return Ok(Err(Error::Status(INVALID_INPUT))); + }; + match drafted_cone(k, &cyl, n, inside, p3(neutral_origin), normal, along, angle)? { + Ok(cone) => surfaces.push(Surface::from(cone)), + Err(e) => return Ok(Err(e)), + } + continue; + } + let SurfaceKind::Plane(plane) = surf.kind_with_nurb(k)? else { + return Ok(Err(Error::Unsupported)); + }; + // The face's outward normal: the plane's, turned with the outer faceuse. + let mut n = plane.position().z_axis(); + if w.faceuse(fu)?.orientation != OrientType::Same { + n = -n; + } + if n.dot(along).abs() > 1e-6 { + return Ok(Err(Error::Unsupported)); + } + let Some(inside) = answer(face_internal_point(k, &mut w, f))? else { + return Ok(Err(Error::Status(INVALID_INPUT))); + }; + let toward = along.dot(normal); + if toward.abs() < 1e-12 { + return Ok(Err(Error::Argument)); + } + let hinge = inside + along * ((p3(neutral_origin) - inside).dot(normal) / toward); + // Turning n about n x pull by `angle` gives n cos(angle) + pull sin(angle). + let axis = n.cross(along); + let len = axis.length(); + if !(len > 0.5) { + return Ok(Err(Error::Unsupported)); + } + let axis = axis * (1.0 / len); + let Some(surf) = w.face(f)?.surface.as_ref() else { + return Ok(Err(Error::Status(INVALID_INPUT))); + }; + let Some(mut tilted) = answer(surf.copy(k))? else { + return Ok(Err(Error::Status(INVALID_INPUT))); + }; + let mut turn = Axis2Placement::IDENTITY; + turn.rotate_about_axis_at_point(angle, hinge, axis); + if answer(tilted.transform(k, &turn, None))?.is_none() { + return Ok(Err(Error::Status(INVALID_INPUT))); + } + surfaces.push(tilted); + } + // `moved_faces(b, status(b->LocalOperation(faces, surfaces)))`, its marks on the picked + // faces (`mark_moved`). + mark_moved(&mut w, &picked)?; + if let Err(e) = called(local_operation_simple(k, &mut w, &picked, surfaces))? { + return Ok(Err(e)); + } + if !moved_faces(&mut w)? { + return Ok(Err(Error::NoEffect)); + } + finish(w, true) + }) +} diff --git a/crates/smlib/src/core/offset_tests.rs b/crates/smlib/src/core/offset_tests.rs new file mode 100644 index 0000000000..6241ed1094 --- /dev/null +++ b/crates/smlib/src/core/offset_tests.rs @@ -0,0 +1,909 @@ +//! Shells, face offsets, thickening, sheet offsets and drafts on solidcore, against closed forms: +//! every result exports to a record that validates. With SMLib built, the same case on the C++ +//! kernel gives the same volume (or, for a sheet, area), counts and faces by surface kind, or the +//! same error (parity only: the expected values are the analytic ones). +//! +//! The bodies are solidcore's own boxes, cylinders and linear sweeps (and booleans of them). + +use std::collections::BTreeMap; +use std::f64::consts::PI; +use std::sync::mpsc; +use std::time::Duration; + +use solidcore::curve::curve::Curve; +use solidcore::curve::ellipse::Ellipse; +use solidcore::geom::axis2_placement::Axis2Placement; +use solidcore::geom::extent1d::Extent1d; +use solidcore::geom::vector3d::Vec3; +use solidcore::primitive::api_curves::create_line_segment; +use solidcore::sweep::api_brep::create_linear_sweep; + +use super::offset::{draft, mark_moved, moved_faces, offset_faces, offset_sheet, shell, thicken}; +use super::{Body, area, bounds, counts, make_box, make_cylinder, make_sphere, run, to_arrays, volume}; +use crate::export::native; +use crate::{BooleanOp, Error}; + +/// A case longer than this is a hang. +const TIMEOUT: Duration = Duration::from_secs(300); + +/// How near a pick point must be. +const TOL: f64 = 0.01; + +#[derive(Clone, Copy, Debug)] +enum Shape { + Box([f64; 3], [f64; 3]), + Cylinder([f64; 3], f64, f64), + Sphere([f64; 3], f64), + /// A frustum about +Z: base centre, base radius, top radius, height. + Frustum([f64; 3], f64, f64, f64), + /// A sheet: the line from the first point to the second swept along `dir` by `dist`. + LineSheet([f64; 3], [f64; 3], [f64; 3], f64), + /// A sheet: the arc about +Z through `center` of `radius` from 0 to `degrees`, swept along + /// +Z by `height`. + ArcSheet([f64; 3], f64, f64, f64), + /// Rig case shell/4711: a 60 x 40 x 10 block less an r3 through hole at (22.5, 7) and a + /// 10 x 10 notch on the front edge from x = 25 to 35. + Notched, +} + +fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +impl Shape { + fn core(self) -> Body { + match self { + Shape::Box(o, s) => make_box(o, s).unwrap(), + Shape::Cylinder(o, r, h) => make_cylinder(o, r, h).unwrap(), + Shape::Sphere(o, r) => make_sphere(o, r).unwrap(), + Shape::Frustum(o, a, b, h) => super::make_cone(o, a, b, h).unwrap(), + Shape::LineSheet(a, b, dir, dist) => run("sheet", |k| { + let c = Curve::from(create_line_segment(v3(a), v3(b))?); + create_linear_sweep(k, &[Some(&c)], v3(dir), dist, false) + }) + .map(Body::new) + .unwrap(), + Shape::ArcSheet(o, r, degrees, h) => run("sheet", |k| { + let frame = Axis2Placement::new(v3(o), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0)); + let c = Curve::from(Ellipse::create_canonical_circle(k, &frame, r, Some(&Extent1d::from_min_max(0.0, degrees)), None)?); + create_linear_sweep(k, &[Some(&c)], Vec3::new(0.0, 0.0, 1.0), h, false) + }) + .map(Body::new) + .unwrap(), + Shape::Notched => { + let block = make_box([0.0; 3], [60.0, 40.0, 10.0]).unwrap(); + let pin = make_cylinder([22.5, 7.0, -5.0], 3.0, 20.0).unwrap(); + let notch = make_box([25.0, -5.0, -5.0], [10.0, 10.0, 20.0]).unwrap(); + let a = super::boolean::boolean(&block, &pin, BooleanOp::Subtract).unwrap().unwrap(); + super::boolean::boolean(&a, ¬ch, BooleanOp::Subtract).unwrap().unwrap() + } + } + } +} + +#[derive(Clone, Debug)] +enum Op { + /// `Brep::shell`: open faces, thickness. + Shell(Shape, Vec<[f64; 3]>, f64), + /// `Brep::offset_faces`: faces, distance. + OffsetFaces(Shape, Vec<[f64; 3]>, f64), + /// `Brep::thicken`: thickness, symmetric. + Thicken(Shape, f64, bool), + /// `Brep::offset_sheet`: distance. + OffsetSheet(Shape, f64), + /// `Brep::draft`: faces, neutral origin, neutral normal, pull, angle. + Draft(Shape, Vec<[f64; 3]>, [f64; 3], [f64; 3], [f64; 3], f64), +} + +/// What a case must give. +#[derive(Clone, Debug)] +enum Expect { + /// A solid of this volume (relative 1e-9). + Volume(f64), + /// A solid of this volume whose bounds along z are these. + Slab(f64, f64, f64), + /// A solid of this volume (relative 1e-9) whose bounds are these. + Solid(f64, [f64; 3], [f64; 3]), + /// A sheet of this area (relative 1e-9) whose bounds are these. + Sheet(f64, [f64; 3], [f64; 3]), + /// This error. + Fails(Error), +} + +struct Case { + op: Op, + expect: Expect, +} + +/// A 30 x 20 x 10 block from the origin. +const BLOCK: Shape = Shape::Box([0.0; 3], [30.0, 20.0, 10.0]); +const ABC: f64 = 6000.0; +/// A point on its top face. +const TOP: [f64; 3] = [15.0, 10.0, 10.0]; +/// A point on its front face (y = 0). +const FRONT: [f64; 3] = [15.0, 0.0, 5.0]; +/// The z axis: a neutral plane's normal and a pull. +const Z: [f64; 3] = [0.0, 0.0, 1.0]; +/// A 30 x 20 sheet in z = 0: the x axis from 0 to 30 swept 20 along y. +const PLATE: Shape = Shape::LineSheet([0.0; 3], [30.0, 0.0, 0.0], [0.0, 1.0, 0.0], 20.0); +/// A sphere of radius 10 about the origin. +const BALL: Shape = Shape::Sphere([0.0; 3], 10.0); +/// A quarter of a cylinder of radius 5 about the z axis, 10 high. +const QUARTER: Shape = Shape::ArcSheet([0.0; 3], 5.0, 90.0, 10.0); + +fn case(name: &str) -> Case { + let c = |op, expect| Case { op, expect }; + match name { + // abc - (a - 2t)(b - 2t)(c - t), t = 2. + "shell_box_open_top" => c(Op::Shell(BLOCK, vec![TOP], -2.0), Expect::Volume(ABC - 26.0 * 16.0 * 8.0)), + // A void: abc - (a - 2t)(b - 2t)(c - 2t). + "shell_box_closed" => c(Op::Shell(BLOCK, vec![], -2.0), Expect::Volume(ABC - 26.0 * 16.0 * 6.0)), + // Closed and outward, the convex edges extended: (a + 2t)(b + 2t)(c + 2t) - abc, t = 1. + "shell_box_closed_outward" => c(Op::Shell(BLOCK, vec![], 1.0), Expect::Volume(32.0 * 22.0 * 12.0 - ABC)), + // Outward, the convex edges extended: (a + 2t)(b + 2t)(c + t) - abc, t = 1. + "shell_box_outward" => c(Op::Shell(BLOCK, vec![TOP], 1.0), Expect::Volume(32.0 * 22.0 * 11.0 - ABC)), + // r = 5, h = 10, open at the top, t = 1: π r² h - π (r - t)² (h - t). + "shell_cylinder_open_top" => { + c(Op::Shell(Shape::Cylinder([0.0; 3], 5.0, 10.0), vec![[0.0, 2.0, 10.0]], -1.0), Expect::Volume(PI * 25.0 * 10.0 - PI * 16.0 * 9.0)) + } + // A wall of 15 inside a block 20 wide leaves no cavity. + "shell_too_thick" => c(Op::Shell(BLOCK, vec![TOP], -15.0), Expect::Fails(Error::NoRoom)), + // The hole's offset (r 3 + 2) is tangent to the front's (y = 2): the walls meet along a + // line, which bounds no solid. + "shell_walls_touch" => c(Op::Shell(Shape::Notched, vec![[50.0, 30.0, 0.0]], -2.0), Expect::Fails(Error::WallsTouch)), + "shell_pick_near_nothing" => c(Op::Shell(BLOCK, vec![TOP, [100.0; 3]], -2.0), Expect::Fails(Error::NoMatch { point: 1 })), + // The top front edge's midpoint is as near the top as the front. + "shell_pick_ambiguous" => c(Op::Shell(BLOCK, vec![[15.0, 0.0, 10.0]], -2.0), Expect::Fails(Error::Ambiguous { point: 0 })), + // A plate of 30 x 20 thickened 2: 1200, 2 thick along z; symmetric, from -1 to 1. + "thicken_plate" => c(Op::Thicken(PLATE, 2.0, false), Expect::Slab(1200.0, 0.0, 2.0)), + "thicken_plate_symmetric" => c(Op::Thicken(PLATE, 2.0, true), Expect::Slab(1200.0, -1.0, 1.0)), + "thicken_a_solid" => c(Op::Thicken(BLOCK, 2.0, false), Expect::Fails(Error::Argument)), + // The plate moved 3 along its normal (+z: the x axis swept along y): still 600, at z = 3. + "offset_sheet_plate" => c(Op::OffsetSheet(PLATE, 3.0), Expect::Sheet(600.0, [0.0, 0.0, 3.0], [30.0, 20.0, 3.0])), + // A sphere's one face closes on itself (tangency-labs/smlib fix #17): r = 10 moved out 2 is + // the ball of radius 12, (4/3) π 12³; moved in 10, to its centre, nothing is left. + "offset_sheet_a_sphere" => c(Op::OffsetSheet(BALL, 2.0), Expect::Volume(4.0 / 3.0 * PI * 12.0f64.powi(3))), + "offset_sheet_a_sphere_to_its_centre" => c(Op::OffsetSheet(BALL, -10.0), Expect::Fails(Error::Empty)), + // The quarter cylinder moved 2 along its normal (outward: the arc's tangent swept along + // z): radius 7, area (π/2) 7 · 10. + "offset_sheet_quarter_cylinder" => c(Op::OffsetSheet(QUARTER, 2.0), Expect::Sheet(PI / 2.0 * 7.0 * 10.0, [0.0, 0.0, 0.0], [7.0, 7.0, 10.0])), + // A closed sheet offsets as an open one (tangency-labs/smlib fix #17, develop 9d0fb564): + // each face moved out 1 along its normal, the convex edges extended, gives the block + // (a + 2d)(b + 2d)(c + 2d) of six planes, from -d to (a, b, c) + d. + "offset_sheet_a_solid" => c(Op::OffsetSheet(BLOCK, 1.0), Expect::Solid(32.0 * 22.0 * 12.0, [-1.0, -1.0, -1.0], [31.0, 21.0, 11.0])), + // The top moved out 2: abc + d·ab. + "offset_faces_top_out" => c(Op::OffsetFaces(BLOCK, vec![TOP], 2.0), Expect::Volume(ABC + 2.0 * 600.0)), + // The top moved in 10, as deep as the block: nothing left behind it. + "offset_faces_through" => c(Op::OffsetFaces(BLOCK, vec![TOP], -10.0), Expect::Fails(Error::NoRoom)), + // The four sides drafted θ = 5° about the bottom (z = 0), pulled along +z, leaning in: + // the section at height z is (a - 2 z tanθ)(b - 2 z tanθ), so + // V = abh - (a + b) h² tanθ + (4/3) h³ tan²θ. + "draft_box_sides" => { + let s = (5.0f64).to_radians().tan(); + let (a, b, h) = (30.0, 20.0, 10.0); + let sides = vec![[15.0, 0.0, 5.0], [30.0, 10.0, 5.0], [15.0, 20.0, 5.0], [0.0, 10.0, 5.0]]; + c( + Op::Draft(BLOCK, sides, [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 5f64.to_radians()), + Expect::Volume(a * b * h - (a + b) * h * h * s + 4.0 / 3.0 * h * h * h * s * s), + ) + } + // The front (y = 0) alone drafted θ = 5° about the bottom: a wedge of a h² tanθ / 2 + // comes off along its length. + "draft_one_side" => { + let s = (5.0f64).to_radians().tan(); + c(Op::Draft(BLOCK, vec![FRONT], [0.0; 3], Z, Z, 5f64.to_radians()), Expect::Volume(ABC - 30.0 * 100.0 * s / 2.0)) + } + // A negative angle leans it out: the same wedge goes on. + "draft_one_side_out" => { + let s = (5.0f64).to_radians().tan(); + c(Op::Draft(BLOCK, vec![FRONT], [0.0; 3], Z, Z, -5f64.to_radians()), Expect::Volume(ABC + 30.0 * 100.0 * s / 2.0)) + } + // Hinged at the top (z = 10) and leaning in going up, the face leans out below the + // hinge: the wedge goes on. + "draft_one_side_hinged_at_the_top" => { + let s = (5.0f64).to_radians().tan(); + c(Op::Draft(BLOCK, vec![FRONT], [0.0, 0.0, 10.0], Z, Z, 5f64.to_radians()), Expect::Volume(ABC + 30.0 * 100.0 * s / 2.0)) + } + // A cylinder along the pull drafts to a cone (tangency-labs/smlib fix #27, develop + // 9d0fb564): R = 5, h = 10, θ = 0.1 about the bottom, leaning in: a frustum of radii R + // and r = R - h tanθ (a cone and its two caps): V = π h (R² + R r + r²) / 3. + "draft_a_curved_face" => { + let (big, h) = (5.0, 10.0); + let small = big - h * 0.1f64.tan(); + c( + Op::Draft(Shape::Cylinder([0.0; 3], big, h), vec![[5.0, 0.0, 5.0]], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 0.1), + Expect::Volume(PI * h * (big * big + big * small + small * small) / 3.0), + ) + } + // A cone r 10 at its base to 8 at its top, 20 tall, drafted 5° about its base turns its + // generators 5° more (fork d51a723): r 10 + 20 tan(atan(-0.1) - 5°) at the top. + "draft_a_cone_about_the_pull" => { + let (big, h, a) = (10.0, 20.0, 5f64.to_radians()); + let small = big + h * ((-0.1f64).atan() - a).tan(); + c( + Op::Draft(Shape::Frustum([0.0; 3], big, 8.0, h), vec![[0.0, 9.0, 10.0]], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], a), + Expect::Volume(PI * h * (big * big + big * small + small * small) / 3.0), + ) + } + // The top is square to the pull, not along it. + "draft_a_face_across_the_pull" => { + c(Op::Draft(BLOCK, vec![TOP], [0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 0.1), Expect::Fails(Error::Unsupported)) + } + _ => panic!("{name}"), + } +} + +/// The tolerance a case picks with: the ambiguous point lies on its two faces. +fn tolerance(name: &str) -> f64 { + if name == "shell_pick_ambiguous" { 1.0 } else { TOL } +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +#[derive(Debug, PartialEq)] +struct Measured { + /// The volume of a solid, the area of a sheet. + size: f64, + bounds: ([f64; 3], [f64; 3]), + counts: [usize; 3], + kinds: BTreeMap, +} + +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 is_sheet(e: &Expect) -> bool { + matches!(e, Expect::Sheet(..)) +} + +fn on_core(name: &str, c: &Case) -> crate::Result { + let tol = tolerance(name); + match &c.op { + Op::Shell(s, points, t) => shell(&s.core(), points, tol, *t), + Op::OffsetFaces(s, points, d) => offset_faces(&s.core(), points, tol, *d), + Op::Thicken(s, t, sym) => thicken(&s.core(), *t, *sym), + Op::OffsetSheet(s, d) => offset_sheet(&s.core(), *d), + Op::Draft(s, points, o, n, pull, angle) => draft(&s.core(), points, tol, *o, *n, *pull, *angle), + } +} + +/// Runs `f` on its own thread: a hang fails the test instead of stopping the run. +fn timed(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; + +fn near(a: [f64; 3], b: [f64; 3]) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// Checks a kernel's result against the case's closed form; what it measured, or its error. +fn judge(name: &str, who: &str, expect: &Expect, got: Got) -> Got { + match (expect, &got) { + (Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"), + (Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"), + (_, Err(g)) => panic!("{name}: {who} failed: {g}"), + (Expect::Volume(v), Ok(m)) => assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size), + (Expect::Slab(v, lo, hi), Ok(m)) => { + assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size); + let (a, b) = (m.bounds.0[2], m.bounds.1[2]); + assert!((a - lo).abs() < 1e-6 && (b - hi).abs() < 1e-6, "{name}: {who} z from {a} to {b}, expected {lo} to {hi}"); + } + (Expect::Solid(v, lo, hi), Ok(m)) => { + assert!(rel(m.size, *v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.size); + assert!(near(m.bounds.0, *lo) && near(m.bounds.1, *hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds); + } + (Expect::Sheet(s, lo, hi), Ok(m)) => { + assert!(rel(m.size, *s) < 1e-9, "{name}: {who} area {} vs analytic {s}", m.size); + assert!(near(m.bounds.0, *lo) && near(m.bounds.1, *hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds); + } + } + got +} + +fn solidcore_case(name: &'static str) -> Got { + timed(name, move || { + let c = case(name); + let sheet = is_sheet(&c.expect); + let got = on_core(name, &c).map(|body| { + let size = if sheet { area(&body, 1e-8) } else { volume(&body, 1e-8) }.unwrap_or_else(|e| panic!("{name}: size: {e}")); + let n = counts(&body).unwrap(); + let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); + let bx = bounds(&body).unwrap(); + Measured { size, bounds: bx, counts: n, kinds: kinds(&rec) } + }); + judge(name, "solidcore", &c.expect, got) + }) +} + +/// The case on the C++ kernel, called directly (whatever the choice). +#[cfg(feature = "native")] +fn cpp_case(name: &str) -> Got { + use crate::ops::pick_error; + use crate::sweeps::DraftCorner; + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let line = |a: [f64; 3], b: [f64; 3]| crate::sweeps::Curve::Line { start: a, end: b }; + let make = |s: Shape| -> Brep { + match s { + Shape::Box(o, z) => ffi::make_box(&o, &z).map(Brep::made).unwrap(), + Shape::Cylinder(o, r, h) => ffi::make_cylinder(&o, r, h).map(Brep::made).unwrap(), + Shape::Sphere(o, r) => ffi::make_sphere(&o, r).map(Brep::made).unwrap(), + Shape::Frustum(o, a, b, h) => ffi::make_cone(&o, a, b, h).map(Brep::made).unwrap(), + Shape::LineSheet(a, b, dir, dist) => Brep::extrude(&[line(a, b)], dir, dist, 0.0, DraftCorner::default(), false).unwrap(), + Shape::ArcSheet(o, r, degrees, h) => { + let arc = crate::sweeps::Curve::Arc { + center: o, + axis: [0.0, 0.0, 1.0], + reference: [1.0, 0.0, 0.0], + radius: r, + start: 0.0, + end: degrees.to_radians(), + }; + Brep::extrude(&[arc], [0.0, 0.0, 1.0], h, 0.0, DraftCorner::default(), false).unwrap() + } + Shape::Notched => { + let block = ffi::make_box(&[0.0; 3], &[60.0, 40.0, 10.0]).unwrap(); + let pin = ffi::make_cylinder(&[22.5, 7.0, -5.0], 3.0, 20.0).unwrap(); + let notch = ffi::make_box(&[25.0, -5.0, -5.0], &[10.0, 10.0, 20.0]).unwrap(); + let a = ffi::ops::boolean(&block, &pin, ffi::ops::BOOLEAN_SUBTRACT).unwrap(); + Brep::made(ffi::ops::boolean(&a, ¬ch, ffi::ops::BOOLEAN_SUBTRACT).unwrap()) + } + } + }; + let c = case(name); + let tol = tolerance(name); + let r = match &c.op { + Op::Shell(s, points, t) => ffi::ops::pick_op(ffi::ops::Pick::Shell, make(*s).cpp().unwrap(), points, tol, *t).map_err(pick_error), + Op::OffsetFaces(s, points, d) => ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, make(*s).cpp().unwrap(), points, tol, *d).map_err(pick_error), + Op::Thicken(s, t, sym) => ffi::ops::thicken(make(*s).cpp().unwrap(), *t, *sym).map_err(error), + Op::OffsetSheet(s, d) => ffi::sheets::offset_sheet(make(*s).cpp().unwrap(), *d).map_err(error), + Op::Draft(s, points, o, n, pull, angle) => ffi::ops::draft(make(*s).cpp().unwrap(), points, tol, o, n, pull, *angle).map_err(pick_error), + }; + let sheet = is_sheet(&c.expect); + let got = r.map(|h| { + let size = if sheet { ffi::measure::area(&h, 1e-8) } else { ffi::volume(&h, 1e-8) }.unwrap(); + let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap()); + let bx = ffi::measure::bounds(&h).unwrap(); + let b = Brep::made(h); + let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + Measured { size, bounds: bx, counts: n, kinds: kinds(&rec) } + }); + judge(name, "C++", &c.expect, got) +} + +/// solidcore and C++ agree: volume (area) within 1e-9, the same counts and faces by kind, or the +/// same error. +#[cfg(feature = "native")] +fn parity(name: &'static str) { + let cpp = cpp_case(name); + let ours = solidcore_case(name); + match (ours, cpp) { + (Ok(a), Ok(b)) => { + assert!(rel(a.size, b.size) < 1e-9, "{name}: solidcore {} vs C++ {}", a.size, b.size); + assert_eq!(a.counts, b.counts, "{name}: face, edge, vertex counts (solidcore vs C++)"); + assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)"); + } + (a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"), + } +} + +macro_rules! cases { + ($($m:ident $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::solidcore_case(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::cpp_case(stringify!($m)); + } + } + )*}; +} + +cases!( + shell_box_open_top, + shell_box_closed, + shell_box_closed_outward, + shell_box_outward, + shell_cylinder_open_top, + shell_too_thick, + shell_walls_touch, + shell_pick_near_nothing, + shell_pick_ambiguous, + thicken_plate, + thicken_plate_symmetric, + thicken_a_solid, + offset_sheet_plate, + offset_sheet_quarter_cylinder, + offset_sheet_a_solid, + offset_sheet_a_sphere, + offset_sheet_a_sphere_to_its_centre, + offset_faces_top_out, + offset_faces_through, + draft_box_sides, + draft_one_side, + draft_one_side_out, + draft_one_side_hinged_at_the_top, + draft_a_curved_face, + draft_a_cone_about_the_pull, + draft_a_face_across_the_pull, +); + +/// The capi's argument checks, before any kernel call. +#[test] +fn hostile_arguments_are_refused() { + let b = BLOCK.core(); + for t in [0.0, f64::NAN, -1e7, f64::INFINITY] { + assert_eq!(shell(&b, &[TOP], TOL, t).err(), Some(Error::Argument), "thickness {t}"); + assert_eq!(thicken(&b, t, false).err(), Some(Error::Argument), "thickness {t}"); + assert_eq!(offset_sheet(&b, t).err(), Some(Error::Argument), "distance {t}"); + assert_eq!(offset_faces(&b, &[TOP], TOL, t).err(), Some(Error::Argument), "distance {t}"); + } + assert_eq!(shell(&b, &[[f64::NAN; 3]], TOL, -1.0).err(), Some(Error::Argument)); + assert_eq!(shell(&b, &[TOP], 0.0, -1.0).err(), Some(Error::Argument)); + assert_eq!(offset_faces(&b, &[], TOL, 1.0).err(), Some(Error::Argument)); + let z = [0.0, 0.0, 1.0]; + assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], [0.0; 3], z, 0.1).err(), Some(Error::Argument), "no neutral normal"); + assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], z, z, 0.0).err(), Some(Error::Argument), "no angle"); + assert_eq!(draft(&b, &[TOP], TOL, [0.0; 3], z, z, PI / 2.0).err(), Some(Error::Argument), "a right angle"); + assert_eq!(draft(&b, &[TOP], TOL, [f64::NAN; 3], z, z, 0.1).err(), Some(Error::Argument)); +} + +/// The body offset is never changed. +#[test] +fn the_body_stays_as_it_was() { + let r = timed("operand", || { + let b = BLOCK.core(); + let _ = shell(&b, &[TOP], TOL, -2.0); + (counts(&b).unwrap(), volume(&b, 1e-8).unwrap()) + }); + assert_eq!(r.0, [6, 12, 8]); + assert!(rel(r.1, ABC) < 1e-12, "{}", r.1); +} + +/// The distance from `p` to each face of `b` (`SmApiFaceClosestPoint`), under `identity`. +fn face_distances( + k: &mut solidcore::kernel::Kernel, + b: &mut solidcore::topology::Brep, + identity: &solidcore::query::brep::BrepIdentity<'_>, + p: Vec3, +) -> solidcore::Result> { + use solidcore::geom::object::Shape as Sh; + use solidcore::query::api_queries::face_closest_point_in_shape; + use solidcore::query::brep::LiveBrepObjects; + use solidcore::topology::BrepId; + let one = BrepId::from_index(0); + let mut out = Vec::new(); + for f in solidcore::topology::brep::p1::faces(b, None)? { + let shape = Sh::new(k, &(one, &*b), &[], &[(one, f)], &[], &[])?; + let objects = LiveBrepObjects::new(b, identity, None)?; + let c = face_closest_point_in_shape(k, &shape, &objects, (one, f), p)?; + out.push((c.distance, c.point)); + } + Ok(out) +} + +/// The closed form for (15, 10, 20) above the 30 x 20 x 10 box at the origin, face by face of +/// `create_box`: bottom 20, top 10, the x sides sqrt(15^2 + 10^2), the y sides sqrt(10^2 + 10^2). +fn box_face_distances_are_exact(what: &str, d: &[(f64, Vec3)]) { + let want = [20.0, 10.0, 325f64.sqrt(), 325f64.sqrt(), 200f64.sqrt(), 200f64.sqrt()]; + assert_eq!(d.len(), want.len(), "{what}: faces"); + for (i, (&(got, _), want)) in d.iter().zip(want).enumerate() { + assert!((got - want).abs() < 1e-9, "{what}: face {i} at {got}, want {want}"); + } + let top = d[1].1; + assert!((top.x - 15.0).abs() < 1e-9 && (top.y - 10.0).abs() < 1e-9 && (top.z - 10.0).abs() < 1e-9, "{what}: top {top:?}"); +} + +/// Picking faces on a solidcore box: on the body itself, on the working copy every operation +/// makes (`brep_copy`), and by solidcore's recommended usage, each face's distance is the closed +/// form and the top face is picked. +#[test] +fn faces_are_picked_on_the_box_and_on_its_working_copy() { + use solidcore::query::brep::{BrepIdentity, BrepQuery}; + let p = Vec3::new(15.0, 10.0, 20.0); + let on_top = [[15.0, 10.0, 10.0]]; + run("pick", |k| { + let mut b = solidcore::primitive::api_primitives::create_box(k, Vec3::new(0.0, 0.0, 0.0), 30.0, 20.0, 10.0)?; + let mut w = solidcore::topology::brep::p3::brep_copy(k, &b)?; + let mut faces = Vec::new(); + for (what, x) in [("the body", &mut b), ("its copy", &mut w)] { + let query = BrepQuery::of_brep(x); + let identity = query.effective_identity(); + box_face_distances_are_exact(what, &face_distances(k, x, &identity, p)?); + let picked = super::offset::pick_faces(k, x, &on_top, TOL)?; + faces.push(picked.ok()); + } + assert!(faces[0].as_ref().is_some_and(|f| f.len() == 1), "the body: {faces:?}"); + assert_eq!(faces[0], faces[1], "the body and its copy pick the same face"); + // `BrepIdentity::of_brep` honours a copy's owner keys since solidcore#1445: the body + // and its copy both resolve. + for (what, x) in [("of_brep on the body", &mut b), ("of_brep on its copy", &mut w)] { + let proven = BrepIdentity::of_brep(x); + box_face_distances_are_exact(what, &face_distances(k, x, &proven.identity(), p)?); + } + Ok(()) + }) + .unwrap(); +} + +/// A pick skips an entity whose query returns an SMLib status, as the capi skips any status +/// other than `SM_SUCCESS`; any other solidcore error fails the pick. +#[test] +fn a_solidcore_error_in_a_pick_is_an_error() { + use super::answer; + use super::blend::pick; + use solidcore::SolidError; + let all = [0usize, 1]; + let points = [[0.0; 3]]; + let status = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::sm_err("q")) } else { Ok(0.5) })); + assert_eq!(status.unwrap().unwrap().0, vec![1], "a status skips the entity"); + let other = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::invalid_input("q", "owner")) } else { Ok(0.5) })); + assert!(matches!(other, Err(SolidError::InvalidInput { .. })), "{other:?}"); + let unported = pick(&all, &points, 1.0, 1e-6, |i, _| answer(if i == 0 { Err(SolidError::unported("Sm::X", "u")) } else { Ok(0.5) })); + assert!(matches!(unported, Err(SolidError::Unported { .. })), "{unported:?}"); +} + +/// The capi's `moved_faces` reading: no mark, no move (SMLIB_ERR_NO_EFFECT); a mark is read once, +/// and gone. +#[test] +fn a_draft_with_no_marked_face_left_has_no_effect() { + let body = make_box([0.0; 3], [10.0; 3]).unwrap(); + super::with_body("marks", &body, |_, w| { + assert!(!moved_faces(w)?, "no face marked"); + let f = w.face_list()[0]; + mark_moved(w, &[f])?; + assert!(moved_faces(w)?, "a face marked"); + assert!(!moved_faces(w)?, "the mark removed"); + Ok(()) + }) + .unwrap(); +} + +/// A draft leaves no mark of its own on what it returns, on a plane or on a cylinder turned cone. +#[test] +fn a_draft_leaves_no_mark() { + let z = [0.0, 0.0, 1.0]; + let bx = make_box([0.0; 3], [20.0, 30.0, 10.0]).unwrap(); + let plane = draft(&bx, &[[10.0, 0.0, 5.0]], TOL, [0.0; 3], z, z, 0.1).unwrap(); + let cyl = make_cylinder([0.0; 3], 5.0, 10.0).unwrap(); + let cone = draft(&cyl, &[[5.0, 0.0, 5.0]], TOL, [0.0; 3], z, z, 0.1).unwrap(); + for b in [plane, cone] { + super::with_body("marks", &b, |_, w| { + assert!(!moved_faces(w)?); + Ok(()) + }) + .unwrap(); + } +} + +/// `moved_faces` on both kernels: drafts so small they leave a plane or cylinder where it was +/// still moved a face (SMLib's mark and solidcore's both stay on it), an angle that brings a wall +/// onto the plane of the wall above it leaves the two faces apart, and no draft here is +/// SMLIB_ERR_NO_EFFECT on either: the same answer, volume and counts. The C++ check holds a local +/// operation to what it promised and no body found so far trips it. +#[cfg(feature = "native")] +#[test] +fn a_draft_moves_a_face_as_on_cpp() { + let _cpp = crate::route::on_cpp(); + use crate::sweeps::{Curve, DraftCorner}; + use crate::{Brep, ffi}; + crate::init().unwrap(); + let z = [0.0, 0.0, 1.0]; + let make = |h: ffi::Handle| Brep::made(h); + let plate = make(ffi::make_box(&[0.0; 3], &[40.0, 40.0, 10.0]).unwrap()); + let rod = make(ffi::make_cylinder(&[20.0, 20.0, -5.0], 5.0, 30.0).unwrap()); + let holed = make(ffi::ops::boolean(plate.cpp().unwrap(), rod.cpp().unwrap(), ffi::ops::BOOLEAN_SUBTRACT).unwrap()); + let pinned = make(ffi::ops::boolean(plate.cpp().unwrap(), rod.cpp().unwrap(), ffi::ops::BOOLEAN_UNION).unwrap()); + let t = 5f64.to_radians().tan(); + // A wall x = 20 up to z = 10, then a wall leaning in 5 degrees: the lower wall turned 5 + // degrees about z = 10 lies on the upper one's plane. + let p = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 0.0, 10.0], [20.0 - 10.0 * t, 0.0, 20.0], [0.0, 0.0, 20.0]]; + let profile: Vec = (0..5).map(|i| Curve::Line { start: p[i], end: p[(i + 1) % 5] }).collect(); + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let leaning = Brep::extrude(&profile, [0.0, 1.0, 0.0], 10.0, 0.0, DraftCorner::default(), true).unwrap(); + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let five = 5f64.to_radians(); + let cases: [(&str, &Brep, [f64; 3], [f64; 3], f64); 7] = [ + ("tiny on a plane", &plate, [20.0, 0.0, 5.0], [0.0; 3], 1e-14), + ("tiny on a hole", &holed, [25.0, 20.0, 5.0], [0.0, 0.0, 5.0], 1e-14), + ("tiny on a pin", &pinned, [25.0, 20.0, 20.0], [0.0, 0.0, 5.0], -1e-14), + ("small on a hole", &holed, [25.0, 20.0, 5.0], [0.0, 0.0, 5.0], 1e-6), + ("onto the wall above", &leaning, [20.0, 5.0, 5.0], [0.0, 0.0, 10.0], five), + ("away from the wall above", &leaning, [20.0, 5.0, 5.0], [0.0, 0.0, 10.0], -five), + ("hinged far off", &plate, [20.0, 0.0, 5.0], [0.0, 0.0, 1000.0], five), + ]; + for (name, b, at, origin, angle) in cases { + let cpp = ffi::ops::draft(b.cpp().unwrap(), &[at], TOL, &origin, &z, &z, angle).unwrap(); + let (v, n) = (ffi::volume(&cpp, 1e-8).unwrap(), ffi::counts(&cpp).unwrap().map(|x| usize::try_from(x).unwrap())); + let core = draft(b.core().unwrap(), &[at], TOL, origin, z, z, angle).unwrap(); + let w = volume(&core, 1e-8).unwrap(); + assert!(rel(w, v) < 1e-9, "{name}: solidcore {w} vs C++ {v}"); + assert_eq!(counts(&core).unwrap(), n, "{name}"); + } +} + +/// The tip cut off a triangular prism, drafted about a plane 100 mm off so that the whole face goes +/// out past where its neighbours meet: its edges vanish, `SM_ERR_OFFSET_EDGE_VANISHES` in SMLib, +/// which the capi's `status` answers as `SMLIB_ERR_UNSUPPORTED`, on both kernels. +#[cfg(feature = "native")] +#[test] +fn a_draft_past_the_neighbours_meeting_is_unsupported_as_on_cpp() { + let _cpp = crate::route::on_cpp(); + use crate::sweeps::{Curve, DraftCorner}; + use crate::{Brep, ffi}; + crate::init().unwrap(); + let p = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [11.0, 18.0, 0.0], [9.0, 18.0, 0.0]]; + let profile: Vec = (0..4).map(|i| Curve::Line { start: p[i], end: p[(i + 1) % 4] }).collect(); + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let prism = Brep::extrude(&profile, [0.0, 0.0, 1.0], 10.0, 0.0, DraftCorner::default(), true).unwrap(); + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let (tip, z, origin, angle) = ([[10.0, 18.0, 5.0]], [0.0, 0.0, 1.0], [0.0, 0.0, -100.0], (-2f64).to_radians()); + let cpp = ffi::ops::draft(prism.cpp().unwrap(), &tip, TOL, &origin, &z, &z, angle); + assert_eq!(cpp.err(), Some((ffi::ops::ERR_UNSUPPORTED, None))); + assert_eq!(draft(prism.core().unwrap(), &tip, TOL, origin, z, z, angle).err(), Some(Error::Unsupported)); +} + +/// A flat L of two faces thickened by 2 with mitred walls: 2 x 10 x (20 + 22), as the C++ kernel +/// makes it. +#[cfg(feature = "native")] +#[test] +fn an_l_sheet_thickens_as_on_cpp() { + let _cpp = crate::route::on_cpp(); + use crate::sweeps::{Curve, DraftCorner}; + use crate::{Brep, ffi}; + crate::init().unwrap(); + let l = [Curve::Line { start: [0.0; 3], end: [20.0, 0.0, 0.0] }, Curve::Line { start: [20.0, 0.0, 0.0], end: [20.0, 20.0, 0.0] }]; + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let sheet = Brep::extrude(&l, [0.0, 0.0, 1.0], 10.0, 0.0, DraftCorner::default(), false).unwrap(); + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let cpp = ffi::ops::thicken(sheet.cpp().unwrap(), 2.0, false).unwrap(); + assert!(rel(ffi::volume(&cpp, 1e-8).unwrap(), 840.0) < 1e-9); + let core = thicken(sheet.core().unwrap(), 2.0, false).unwrap(); + assert!(rel(volume(&core, 1e-8).unwrap(), 840.0) < 1e-9); +} + +/// Face and edge tags (`Brep::tagged`) through offsets on solidcore, as on C++: the box is +/// tagged on the C++ kernel, its solidcore body converted from that and tagged the same way, and +/// the two results' histories must be the same. +#[cfg(feature = "native")] +mod history { + use crate::{Brep, ffi}; + + fn tagged_box() -> Brep { + crate::init().unwrap(); + Brep::made(ffi::make_box(&[0.0; 3], &[20.0, 30.0, 10.0]).unwrap()).tagged(0).unwrap() + } + + /// The box hollowed to 2 mm walls, its top open: the five walls offset inward stand for the + /// faces they offset. + #[test] + fn tags_cross_a_shell() { + let _cpp = crate::route::on_cpp(); + let a = tagged_box(); + let top = [[10.0, 15.0, 10.0]]; + let cpp = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::Shell, a.cpp().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap(); + let core = Brep::made_core(super::shell(a.core().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } + + /// The top moved down 2 mm: it stands for itself, its four neighbours are trimmed to meet it. + #[test] + fn tags_cross_a_face_offset() { + let _cpp = crate::route::on_cpp(); + let a = tagged_box(); + let top = [[10.0, 15.0, 10.0]]; + let cpp = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, a.cpp().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap(); + let core = Brep::made_core(super::offset_faces(a.core().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } + + /// A flat sheet and a half-round open extrusion, tagged on the C++ kernel. + fn tagged_sheet(curved: bool) -> Brep { + use std::f64::consts::PI; + + use crate::sweeps::{Curve, DraftCorner}; + crate::init().unwrap(); + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let b = if curved { + let arc = [Curve::Arc { center: [0.0; 3], axis: [0.0, 0.0, 1.0], reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: PI }]; + Brep::extrude(&arc, [0.0, 0.0, 1.0], 20.0, 0.0, DraftCorner::default(), false) + } else { + let c = [[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 10.0, 0.0], [0.0, 10.0, 0.0]]; + let l: Vec = (0..4).map(|i| Curve::Line { start: c[i], end: c[(i + 1) % 4] }).collect(); + Brep::planar_faces(&l) + }; + crate::sweeps::ON_CPP.with(|c| c.set(false)); + b.unwrap().tagged(0).unwrap() + } + + /// A sheet thickened to one side and to both: both faces stand for the sheet's (the histories + /// `offset_backend` reads), its edges and their offsets for its edges, the walls for nothing. + fn thicken_parity(curved: bool) { + for symmetric in [false, true] { + let a = tagged_sheet(curved); + let cpp = Brep::made(ffi::ops::thicken(a.cpp().unwrap(), 2.0, symmetric).unwrap()).history().unwrap(); + let core = Brep::made_core(super::thicken(a.core().unwrap(), 2.0, symmetric).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical(), "symmetric {symmetric}"); + } + } + + #[test] + fn tags_cross_a_thicken_of_a_curved_sheet() { + let _cpp = crate::route::on_cpp(); + thicken_parity(true); + } + + #[test] + fn tags_cross_a_thicken_of_a_flat_sheet() { + let _cpp = crate::route::on_cpp(); + thicken_parity(false); + } + + /// A sheet moved along its normal, flat and curved: each face stands for itself. + #[test] + fn tags_cross_a_sheet_offset() { + let _cpp = crate::route::on_cpp(); + for curved in [false, true] { + let a = tagged_sheet(curved); + let cpp = Brep::made(ffi::sheets::offset_sheet(a.cpp().unwrap(), 1.5).unwrap()).history().unwrap(); + let core = Brep::made_core(super::offset_sheet(a.core().unwrap(), 1.5).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical(), "curved {curved}"); + } + } + + /// A cylinder hollowed, its top open: the walls round closed edges, on a curved face. + #[test] + fn tags_cross_a_shell_of_a_cylinder() { + let _cpp = crate::route::on_cpp(); + crate::init().unwrap(); + let a = Brep::made(ffi::make_cylinder(&[0.0; 3], 10.0, 20.0).unwrap()).tagged(0).unwrap(); + let top = [[0.0, 0.0, 20.0]]; + let cpp = Brep::made(ffi::ops::pick_op(ffi::ops::Pick::Shell, a.cpp().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap(); + let core = Brep::made_core(super::shell(a.core().unwrap(), &top, super::TOL, -2.0).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } + + /// A 5 degree draft of the front about the bottom plane: the front tilts, its neighbours' + /// edges move with it. + #[test] + fn tags_cross_a_draft() { + let _cpp = crate::route::on_cpp(); + let a = tagged_box(); + let front = [[10.0, 0.0, 5.0]]; + let (origin, normal, pull, angle) = ([0.0; 3], [0.0, 0.0, 1.0], [0.0, 0.0, 1.0], 5f64.to_radians()); + let cpp = ffi::ops::draft(a.cpp().unwrap(), &front, super::TOL, &origin, &normal, &pull, angle).unwrap(); + let cpp = Brep::made(cpp).history().unwrap(); + let core = Brep::made_core(super::draft(a.core().unwrap(), &front, super::TOL, origin, normal, pull, angle).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } +} + +/// A stepped block, 40 x 30 x 10 with its half x < 20 raised to 20 (rig case +/// shell/cov-replace-face-step-onto-the-higher-face, the fork's `offset_faces_close_a_step_wall`): +/// a top moved onto the other's plane closes the step wall between them to nothing, and the tops +/// are one face of a 6-faced box. +#[test] +fn offset_faces_close_a_step_wall() { + use super::boolean::boolean; + let stepped = boolean(&make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), &make_box([0.0; 3], [20.0, 30.0, 20.0]).unwrap(), BooleanOp::Union) + .unwrap() + .unwrap(); + assert_eq!(counts(&stepped).unwrap()[0], 8); + for (at, d, v, a) in [([30.0, 15.0, 10.0], 10.0, 24000.0, 5200.0), ([10.0, 15.0, 20.0], -10.0, 12000.0, 3800.0)] { + let r = offset_faces(&stepped, &[at], 0.1, d).unwrap(); + let (got, n, area) = (volume(&r, 1e-8).unwrap(), counts(&r).unwrap(), super::area(&r, 1e-8).unwrap()); + assert_eq!(n[0], 6, "d {d}"); + assert!(rel(got, v) < 1e-9 && rel(area, a) < 1e-9, "d {d}: V {got} A {area}"); + } +} + +/// Rig case shell/4492's rounded box: four mitred rounds, r 3, come off a 40 x 30 x 10 box's top +/// together and follow it. From height 0 the outline is the 34 x 24 top moved out by the round's +/// w(z) = sqrt(9 - (z - axis)^2), or 3 below the axis. With the fork's values at +19, and bodies +/// whose rounds go all the way down from -7 and are cut short from -7.5 (solidcore#2027). At -9 +/// solidcore refuses to put the rounds back (solidcore#2020, open): SolidCraft says it does not +/// make that move, where the C++ kernel gives the analytic value, also checked here. With SMLib +/// built, the C++ kernel gives the same at every other move. +#[cfg(feature = "native")] +#[test] +fn mitred_rounds_follow_a_moved_top() { + let _cpp = crate::route::on_cpp(); + mitred_rounds_follow_a_moved_top_check(); +} + +/// The same on a build without the C++ kernel: solidcore's half alone. A test of its own, so that +/// tests/KNOWN_FAILING_TESTS's entry for the C++ comparison (mitred_rounds_follow_a_moved_top) is graded only where it runs. +#[cfg(not(feature = "native"))] +#[test] +fn mitred_rounds_follow_a_moved_top_on_solidcore() { + mitred_rounds_follow_a_moved_top_check(); +} + +fn mitred_rounds_follow_a_moved_top_check() { + use super::blend::{Corners, blend}; + use crate::Blend; + let edges = [[20.0, 0.0, 10.0], [20.0, 30.0, 10.0], [0.0, 15.0, 10.0], [40.0, 15.0, 10.0]]; + let top = [20.0, 15.0, 10.0]; + let rounded = blend(&make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), Blend::Round, &edges, &[3.0; 4], TOL, &[], &[], Corners::Filled).unwrap(); + let analytic = |d: f64| { + let (height, axis, n) = (10.0 + d, 7.0 + d, 20000); + (0..n) + .map(|i| { + let z = height * (f64::from(i) + 0.5) / f64::from(n); + let w = if z < axis { 3.0 } else { (9.0 - (z - axis) * (z - axis)).max(0.0).sqrt() }; + (34.0 + 2.0 * w) * (24.0 + 2.0 * w) * height / f64::from(n) + }) + .sum::() + }; + let moves = [ + (19.0, [10, 20, 12]), + (1.0, [10, 20, 12]), + (-1.0, [10, 20, 12]), + (-6.9, [10, 20, 12]), + (-7.0, [6, 12, 8]), + (-7.5, [6, 12, 8]), + (-8.0, [6, 12, 8]), + (-9.5, [6, 12, 8]), + ]; + for (d, n) in moves { + let moved = offset_faces(&rounded, &[top], 0.1, d).unwrap(); + let v = volume(&moved, 1e-8).unwrap(); + assert_eq!(counts(&moved).unwrap(), n, "d {d}"); + assert!(rel(v, analytic(d)) < 1e-6, "d {d}: {v} vs {}", analytic(d)); + } + assert!(matches!(offset_faces(&rounded, &[top], 0.1, -9.0), Err(Error::Unsupported))); + #[cfg(feature = "native")] + { + use crate::ffi; + crate::init().unwrap(); + let b = ffi::make_box(&[0.0; 3], &[40.0, 30.0, 10.0]).unwrap(); + let round = ffi::ops::blend(ffi::ops::BLEND_ROUND, &b, &edges, &[3.0; 4], TOL).unwrap(); + for (d, _) in moves { + let cpp = ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, &round, &[top], 0.1, d).unwrap(); + let ours = offset_faces(&rounded, &[top], 0.1, d).unwrap(); + let (o, c) = (volume(&ours, 1e-8).unwrap(), ffi::volume(&cpp, 1e-8).unwrap()); + // Below -7 the rounds are cut short: both are within 1e-6 of the analytic value, and at + // -7.5 within 4.7e-9 of each other. + let agree = if d < -7.0 { 1e-8 } else { 1e-9 }; + assert!(rel(o, c) < agree, "d {d}: {o} vs the C++ kernel's {c}"); + } + // At -9 the C++ kernel at 26cc451 refuses it too. + assert!(ffi::ops::pick_op(ffi::ops::Pick::OffsetFaces, &round, &[top], 0.1, -9.0).is_err(), "d -9"); + } +} diff --git a/crates/smlib/src/core/split.rs b/crates/smlib/src/core/split.rs new file mode 100644 index 0000000000..f39044404d --- /dev/null +++ b/crates/smlib/src/core/split.rs @@ -0,0 +1,513 @@ +//! Split Face and Split Body on solidcore: the C API's composition, ported from the fork's +//! `capi/src/smlib_split.cpp` (`smlib_split_faces`, `smlib_split_body`, `cut_face`, +//! `plane_covers`, `slice`, `both_sides`, `smlib_split_body_by_sheet`) and the tool surfaces of `capi/src/smlib_ssi.cpp` +//! (`smlib_surface_plane`, `_cylinder`, `_cone`, `_sphere`, `_torus`, `_nurbs`). +//! +//! As there, every operation works on copies of its body (`SmApiBrepCopy`). A face is cut where +//! a tool meets it the way SMLib's plane cutter cuts a subset of faces: the face's surface is +//! intersected with the tool over the face's UV domain (`CreatePlanarSectionCurves` for a plane +//! reaching over the face, `GlobalSurfaceIntersect` otherwise) and the untrimmed curves go to +//! `SmBrep::MergeCurvesOnSurface` with the tool as the intersecting surface. Unlike the plane +//! cutter, a failed merge fails the call. A body is split by slicing (`SM_BO_SLICE`) copies of it +//! with a one-face sheet on the tool surface, once with the surface turned over; or with a sheet +//! of several faces as it is, its surfaces first turned to agree. +//! +//! A tool is a [`crate::ssi::Surface`]; solidcore's own surface is made from its description +//! ([`Spec`]), as `smlib_ssi.cpp` makes SMLib's, over the surface's natural UV domain. + +use std::sync::PoisonError; + +use solidcore::EntityRef; +use solidcore::boolean::api_brep::boolean_with_options; +use solidcore::boolean::merge::MergeOptions; +use solidcore::geom::a_object::BooleanOperation; +use solidcore::geom::axis2_placement::Axis2Placement; +use solidcore::geom::extent2d::Extent2d; +use solidcore::geom::object::ObjectRef; +use solidcore::geom::tol::{USE_DEFAULT, approx_tol3d}; +use solidcore::geom::vector2d::Vec2; +use solidcore::geom::vector3d::{Point3, Vec3}; +use solidcore::kernel::Kernel; +use solidcore::nurbs::b_spline_curve::p1::KnotType; +use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData}; +use solidcore::nurbs::math::CPoint; +use solidcore::query::brep::BrepFaceServices; +use solidcore::query::face::p1::calculate_bounding_box; +use solidcore::surface::cone::p1::Cone; +use solidcore::surface::plane::p1::Plane; +use solidcore::surface::sphere::{Sphere, SphereOptions}; +use solidcore::surface::surface::Surface; +use solidcore::surface::surface::p2::TsectCurveType; +use solidcore::surface::torus::{Torus, TorusOptions}; +use solidcore::topology::brep::p1 as brep_p1; +use solidcore::topology::brep::p2::{is_manifold_solid, is_sheet}; +use solidcore::topology::brep::p3::{new_brep, with_kernel_callbacks}; +use solidcore::topology::brep::p8::create_face_from_surface; +use solidcore::topology::brep::p9::orient_sheet_surfaces; +use solidcore::topology::brep::p10::merge_curves_on_surface_through_surface; +use solidcore::topology::brep::planar_section::face_planar_section_curves; +use solidcore::topology::brep_cache::CacheObject; +use solidcore::topology::edge::NotifyEnv; +use solidcore::topology::face::p2 as face_p2; +use solidcore::topology::{Brep, EdgeId, FaceId}; + +use super::blend::status; +use super::sweep::build_faces; +use super::{Body, run}; +use crate::ssi::Spec; +use crate::{Error, Result}; +use solidcore::topology::brep::p3::brep_copy; + +/// `SMLIB_SPLIT_MAX_FACES`, `SMLIB_SPLIT_MAX_TOOLS`. +pub(crate) const MAX_FACES: usize = crate::ffi::split::MAX_FACES; +pub(crate) const MAX_TOOLS: usize = crate::ffi::split::MAX_TOOLS; + +/// `SM_ERR`. +const SM_ERR: i32 = 1001; + +/// The capi's statuses as SolidCraft errors: an operation's own result. +type Out = std::result::Result; + +/// A tool surface as solidcore holds it: the surface over its natural UV domain (`smlib_surface`'s +/// `s` and `domain`), and for a plane its point and unit normal (`IsPlanar`). +pub(crate) struct Tool { + pub(super) surface: Surface, + pub(super) domain: Extent2d, + plane: Option<(Point3, Vec3)>, +} + +fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +/// The runner's `unit_cross`. +fn unit_cross(a: Vec3, b: Vec3) -> Vec3 { + let c = a.cross(b); + let l = c.length(); + Vec3::new(c.x / l, c.y / l, c.z / l) +} + +/// `[k0, k0, k1, ...]` as unique values and their multiplicities (`unique_knots`). +fn unique_knots(k: &[f64]) -> (Vec, Vec) { + let (mut values, mut mults): (Vec, Vec) = (Vec::new(), Vec::new()); + for &x in k { + match (values.last(), mults.last_mut()) { + (Some(&last), Some(m)) if last == x => *m += 1, + _ => { + values.push(x); + mults.push(1); + } + } + } + (values, mults) +} + +/// `clamped`: the first and the last knot each p + 1 times. +fn clamped(k: &[f64], p: usize) -> bool { + match (k.first(), k.get(p), k.last(), k.len().checked_sub(1 + p).and_then(|i| k.get(i))) { + (Some(a), Some(b), Some(c), Some(d)) => a == b && c == d, + _ => false, + } +} + +/// The tool's surface as `smlib_ssi.cpp`'s constructors make it, then its natural UV domain +/// (`construct`: finite and not empty, else `SM_ERR_INVALID_INPUT`). +pub(super) fn tool(k: &Kernel, spec: &Spec) -> solidcore::Result> { + let mut plane = None; + let surface = match spec { + Spec::Plane { origin, xaxis, yaxis, u, v } => { + let dom = Extent2d::from_bounds(u[0], v[0], u[1], v[1]); + plane = Some((v3(*origin), unit_cross(v3(*xaxis), v3(*yaxis)))); + Surface::from(Plane::new(v3(*origin), v3(*xaxis), v3(*yaxis), Vec2::new(1.0, 1.0), &dom)) + } + Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => { + let (x, y) = (v3(*xaxis), v3(*yaxis)); + let o = v3(*origin) + unit_cross(x, y) * height[0]; + Surface::from(Cone::new_cylinder_bounded(o, x, y, *radius, angle_deg[0], angle_deg[1], height[1] - height[0], false, false, false)) + } + Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => { + let (x, y) = (v3(*xaxis), v3(*yaxis)); + let (h0, h1) = (height[0], height[1]); + let t = half_angle_deg.to_radians().tan(); + let o = v3(*apex) + unit_cross(x, y) * h0; + let pl = Axis2Placement::new(o, x, y); + Surface::from(Cone::create_canonical(k, &pl, h0 * t, h1 * t, h1 - h0, false, false)?) + } + Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => { + let anal = Extent2d::from_bounds(lon_deg[0], lat_deg[0], lon_deg[1], lat_deg[1]); + let opts = SphereOptions { swap_uv: false, inside_out: false, circle_nurb: None }; + Surface::from(Sphere::new(k, v3(*center), v3(*xaxis), v3(*yaxis), anal, *radius, opts)?) + } + Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => { + let anal = Extent2d::from_bounds(major_deg[0], minor_deg[0], major_deg[1], minor_deg[1]); + Surface::from(Torus::new(k, v3(*center), v3(*xaxis), v3(*yaxis), anal, *major_radius, *minor_radius, TorusOptions::default())?) + } + Spec::Nurbs { degree, nu, nv, knots_u, knots_v, points, weights } => { + let [p, q] = *degree; + let w = |i: usize| weights.as_ref().and_then(|w| w.get(i).copied()).unwrap_or(1.0); + if !clamped(knots_u, p) || !clamped(knots_v, q) { + // SMLib's component constructor clamps the surface to its domain. + let (ku, mu) = unique_knots(knots_u); + let (kv, mv) = unique_knots(knots_v); + let wts: Vec = (0..points.len()).map(w).collect(); + let pts: Vec = points.iter().map(|p| v3(*p)).collect(); + let data = CanonicalSurfaceData { + u_degree: p, + v_degree: q, + control_points: &pts, + form: BSplineSurfaceForm::Unspecified, + u_multiplicities: &mu, + v_multiplicities: &mv, + u_knots: &ku, + v_knots: &kv, + knot_type: KnotType::Unspecified, + weights: Some(&wts), + uv_domain: None, + }; + Surface::from(BSplineSurface::create_canonical(&data)?) + } else { + // The runner's way: the homogeneous net filled directly. + let mut pw = Vec::with_capacity(*nu); + for i in 0..*nu { + let mut row = Vec::with_capacity(*nv); + for j in 0..*nv { + let at = i * nv + j; + let (Some(pt), wt) = (points.get(at), w(at)) else { + return Ok(Err(Error::Argument)); + }; + row.push(CPoint::new(pt[0] * wt, pt[1] * wt, pt[2] * wt, wt)); + } + pw.push(row); + } + let (n, m) = (nu.saturating_sub(1), nv.saturating_sub(1)); + let (r, s) = (knots_u.len().saturating_sub(1), knots_v.len().saturating_sub(1)); + Surface::from(BSplineSurface::from_data(n, m, p, q, r, s, &pw, knots_u, knots_v)?) + } + } + }; + let domain = surface.natural_uv_domain_with_nurb(k)?; + let (lo, hi) = (domain.min(), domain.max()); + if !(lo.x.is_finite() && lo.y.is_finite() && hi.x.is_finite() && hi.y.is_finite() && lo.x < hi.x && lo.y < hi.y) { + return Ok(Err(Error::Status(1007))); + } + Ok(Ok(Tool { surface, domain, plane })) +} + +/// `plane_covers`: whether the plane `tool` reaches over all of `bx` (each corner's foot on the +/// plane lies in the tool's parameter box): then its section with a face in the box is the +/// infinite plane's. +fn plane_covers(k: &Kernel, tool: &Tool, bx: &solidcore::geom::extent3d::Extent3d, tol: f64) -> bool { + let (lo, hi) = (tool.domain.min(), tool.domain.max()); + let at = |u: f64, v: f64| tool.surface.evaluate_point(k, Vec2::new(u, v)).ok(); + let (Some(o), Some(pu), Some(pv)) = (at(lo.x, lo.y), at(hi.x, lo.y), at(lo.x, hi.y)) else { + return false; + }; + // The plane is affine in (u, v): P = o + (u - u0) X + (v - v0) Y. + let x = (pu - o) / (hi.x - lo.x); + let y = (pv - o) / (hi.y - lo.y); + let (a, b, c) = (x.dot(x), x.dot(y), y.dot(y)); + let det = a * c - b * b; + if !(det.abs() > 0.0) { + return false; + } + let (m, mm) = (bx.min(), bx.max()); + (0..8).all(|kk| { + let q = Vec3::new(if kk & 1 != 0 { mm.x } else { m.x }, if kk & 2 != 0 { mm.y } else { m.y }, if kk & 4 != 0 { mm.z } else { m.z }); + let r = q - o; + let (g0, g1) = (x.dot(r), y.dot(r)); + let u = lo.x + (c * g0 - b * g1) / det; + let v = lo.y + (a * g1 - b * g0) / det; + let (su, sv) = (tol / a.sqrt(), tol / c.sqrt()); + u >= lo.x - su && u <= hi.x + su && v >= lo.y - sv && v <= hi.y + sv + }) +} + +/// The name of each edge curve a merge makes (`SmApiCut`'s). +fn curve_ref(e: EdgeId, _type_code: i64) -> ObjectRef { + solidcore::topology::loopuse::notify_ref(EntityRef::Edge(e)) +} + +/// `cut_face`: cuts `face` where `tool` meets it; the faces it was split into besides itself. +fn cut_face(k: &mut Kernel, b: &mut Brep, env: &mut NotifyEnv<'_>, face: FaceId, tool: &Tool, tol: f64) -> solidcore::Result>> { + let Some(surface) = face_p2::surface(b, face)? else { + return Ok(Err(Error::Status(SM_ERR))); + }; + let surface = surface.clone_for_evaluation()?; + let domain = face_p2::uv_domain(b, face)?; + let approx = approx_tol3d(k, approx_tol3d(k, tol) / 4.0); + let bx = match calculate_bounding_box(k, &mut *b, face, false) { + Ok(bx) => bx, + Err(e) => return Ok(Err(status(e))), + }; + let merge = + |k: &mut Kernel, b: &mut Brep, env: &mut NotifyEnv<'_>, mut c3: Vec<_>, uv: Option>| match merge_curves_on_surface_through_surface( + k, + b, + env, + &curve_ref, + face, + tol, + &mut c3, + uv.as_deref(), + Some(tool.surface.clone()), + ) { + Ok((made, _)) => Ok(Ok(made)), + Err(e) => Ok(Err(status(e))), + }; + // A plane reaching over the face sections it exactly (SmPlaneCutter's way); a plane strip, + // like any other tool, is intersected over its own domain. + if let Some((at, normal)) = tool.plane + && plane_covers(k, tool, &bx, tol) + { + // As SmPlaneCutter: a face far from the plane is not sectioned. + if bx.distance_to_plane(at, normal).abs() > tol { + return Ok(Ok(Vec::new())); + } + let section = match face_planar_section_curves(k, b, face, &surface, &domain, at, normal, Some(approx), None) { + Ok(s) => s, + Err(e) => return Ok(Err(status(e))), + }; + if section.curves_3d.is_empty() { + return Ok(Ok(Vec::new())); + } + let uv = (section.uv_curves.len() == section.curves_3d.len()).then_some(section.uv_curves); + return merge(k, b, env, section.curves_3d, uv); + } + // `face->GetSurface()` reaches its face's trimmed cache: the solve runs through the B-rep's + // face services, as solidcore's own face intersections do (a face-owned surface has no cache + // of its own). + let mut services = BrepFaceServices::new(&mut *b); + let found = match surface.global_surface_intersect_with_context( + k, + [Some(CacheObject::Surface(face)), None], + &mut services, + &domain, + &tool.surface, + &tool.domain, + [true, true], + Some(approx), + None, + ) { + Ok(f) => f, + Err(e) => return Ok(Err(status(e))), + }; + // A single point of contact cuts nothing. + let (mut c3, mut uv1) = (Vec::new(), Vec::new()); + for (i, c) in found.curves_3d.into_iter().enumerate() { + if found.curve_types.get(i) == Some(&TsectCurveType::Touching) { + continue; + } + c3.push(c); + uv1.push(found.surface1_uv_curves.get(i).cloned().flatten()); + } + if c3.is_empty() { + return Ok(Ok(Vec::new())); + } + let all_uv = uv1.iter().all(Option::is_some); + merge(k, b, env, c3, all_uv.then_some(uv1)) +} + +/// `smlib_split_faces`: a copy of `body` with faces `faces` (indices in its face order, distinct) +/// cut where each tool meets them, the tools in turn, each cutting the pieces the earlier ones +/// left; and for each face how many faces it became besides itself. `None` when no face is split +/// (`SMLIB_ERR_NO_CUT`). +pub(crate) fn split_faces(body: &Body, faces: &[usize], tools: &[&Spec]) -> Result)>> { + if faces.is_empty() || faces.len() > MAX_FACES || tools.is_empty() || tools.len() > MAX_TOOLS { + return Err(Error::Argument); + } + if body.0.is_poisoned() { + return Err(Error::Internal("smlib_split_faces: the body was left unfinished by an earlier panic".to_owned())); + } + let r = run("smlib_split_faces", |k| { + let mut a = { + let g = body.0.lock().unwrap_or_else(PoisonError::into_inner); + super::dump::input(k, "brep", &g); + let count = brep_p1::faces(&g, None)?.len(); + let mut seen = std::collections::HashSet::new(); + if faces.iter().any(|&f| f >= count || !seen.insert(f)) { + return Ok(Err(Error::Argument)); + } + brep_copy(k, &g)? + }; + let all = brep_p1::faces(&a, None)?; + // Each chosen face and, as tools cut it, its pieces. + let mut pieces: Vec> = Vec::with_capacity(faces.len()); + for &f in faces { + let Some(&face) = all.get(f) else { return Ok(Err(Error::Status(SM_ERR))) }; + pieces.push(vec![face]); + } + let mut made = vec![0usize; faces.len()]; + let mut built = Vec::with_capacity(tools.len()); + for spec in tools { + match tool(k, spec)? { + Ok(t) => built.push(t), + Err(e) => return Ok(Err(e)), + } + } + let editing = a.editing_enabled(); + let doing = k.context.doing_boolean(); + a.set_editing_enabled(true); + k.context.set_doing_boolean(true); + let tol = a.tolerance(); + // The edits notify through the kernel's callbacks with the body's own attributes, so + // the history tags follow each cut (as SMLib's objects notify their attributes). + let cut = with_kernel_callbacks(k, &mut a, |k, a, env| { + let mut total = 0usize; + for t in &built { + for (i, list) in pieces.iter_mut().enumerate() { + let now = list.clone(); + for f in now { + let split = match cut_face(k, a, env, f, t, tol)? { + Ok(s) => s, + Err(e) => return Ok(Err(e)), + }; + if let Some(m) = made.get_mut(i) { + *m += split.len(); + } + total += split.len(); + list.extend(split); + } + } + } + Ok(Ok(total)) + }); + a.set_editing_enabled(editing); + k.context.set_doing_boolean(doing); + a.refresh_cached_topology(); + // The cut's new edges own their curves under the kernel's identities (it records them), + // not the copy's Notify ones, and the check reads the owners by the body's naming. + a.set_naming(solidcore::topology::NamingScheme::Caller); + Ok(match cut? { + Err(e) => Err(e), + Ok(0) => Ok(None), + Ok(_) => Ok(Some((a, made))), + }) + })??; + Ok(r.map(|(b, made)| (Body::new(b), made))) +} + +/// `slice`: a copy of `body` sliced by `sheet` (SMLib's slice reads the side from the surfaces, not +/// the faces' uses). `None` when nothing is left. +fn slice(k: &mut Kernel, body: &Body, sheet: Brep) -> solidcore::Result>> { + let a = { + let g = body.0.lock().unwrap_or_else(PoisonError::into_inner); + brep_copy(k, &g)? + }; + let options = MergeOptions::new(false, false, false, false, false); + let r = match boolean_with_options(k, a, sheet, BooleanOperation::Slice, options) { + Ok(r) => r.result, + Err(e) => return Ok(Err(status(e))), + }; + Ok(Ok((!r.face_list().is_empty()).then_some(r))) +} + +/// `both_sides`: both parts, `side` making each side's sheet (`true`: turned over, for the back). +/// A body that is no manifold solid is [`Error::NotSolid`]; a side left empty is `None` +/// (`SMLIB_ERR_NO_CUT`), and with `whole` so is a part that is no manifold solid: `SM_BO_SLICE` +/// with a sheet that only nicks a region behaves as a difference, leaving the sheet's piece as a +/// slit in the body and a lamina on the other side, which is no cut. +fn both_sides( + k: &mut Kernel, + body: &Body, + whole: bool, + mut side: impl FnMut(&mut Kernel, bool) -> solidcore::Result>, +) -> solidcore::Result>> { + let solid = { + let g = body.0.lock().unwrap_or_else(PoisonError::into_inner); + is_manifold_solid(&g)? + }; + if !solid { + return Ok(Err(Error::NotSolid)); + } + let mut parts = [None, None]; + for (part, flip) in parts.iter_mut().zip([true, false]) { + let sheet = match side(k, flip)? { + Ok(s) => s, + Err(e) => return Ok(Err(e)), + }; + *part = match slice(k, body, sheet)? { + Ok(p) => p, + Err(e) => return Ok(Err(e)), + }; + } + let [Some(back), Some(front)] = parts else { return Ok(Ok(None)) }; + if whole && !(is_manifold_solid(&back)? && is_manifold_solid(&front)?) { + return Ok(Ok(None)); + } + Ok(Ok(Some((back, front)))) +} + +/// One side of `smlib_split_body`: a one-face sheet on the tool's surface, turned over with `flip`. +fn side(k: &mut Kernel, body: &Body, spec: &Spec, flip: bool) -> solidcore::Result> { + let tolerance = body.0.lock().unwrap_or_else(PoisonError::into_inner).tolerance(); + let t = match tool(k, spec)? { + Ok(t) => t, + Err(e) => return Ok(Err(e)), + }; + let mut surface = t.surface.copy(k)?; + if flip && let Err(e) = surface.swap_uv(k) { + return Ok(Err(status(e))); + } + let mut sheet = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + sheet.set_tolerance(tolerance); + let natural = surface.natural_uv_domain_with_nurb(k)?; + if let Err(e) = build_faces(&mut sheet, |s, fenv| create_face_from_surface(k, s, Some(surface), &natural, fenv)) { + return Ok(Err(status(e))); + } + sheet.refresh_cached_topology(); + Ok(Ok(sheet)) +} + +/// `smlib_split_body`: the manifold solid `body` cut in two by the tool: the part behind the +/// surface (its normal points away) and the part in front. A side left empty is `None` +/// (`SMLIB_ERR_NO_CUT`); a body that is no manifold solid is [`Error::NotSolid`]. +pub(crate) fn split_body(body: &Body, spec: &Spec) -> Result> { + if body.0.is_poisoned() { + return Err(Error::Internal("smlib_split_body: the body was left unfinished by an earlier panic".to_owned())); + } + let r = run("smlib_split_body", |k| { + super::dump::input(k, "brep", &body.0.lock().unwrap_or_else(PoisonError::into_inner)); + both_sides(k, body, false, |k, flip| side(k, body, spec, flip)) + })??; + Ok(r.map(|(b, f)| (Body::new(b), Body::new(f)))) +} + +/// `smlib_split_body_by_sheet`: the manifold solid `body` cut in two by `sheet` as it is (one or +/// more faces joined edge to edge), its surfaces turned to agree with face `face`'s +/// (`SmBrep::OrientSheetSurfaces`), for the back all turned over: the part behind that normal and +/// the part in front. A sheet that does not divide the body is `None` (`SMLIB_ERR_NO_CUT`). A +/// `sheet` that is no sheet, or a `face` out of range, is [`Error::Argument`]. +pub(crate) fn split_body_by_sheet(body: &Body, sheet: &Body, face: usize) -> Result> { + if std::ptr::eq(body, sheet) { + return Err(Error::Argument); + } + if body.0.is_poisoned() || sheet.0.is_poisoned() { + return Err(Error::Internal("smlib_split_body_by_sheet: a body was left unfinished by an earlier panic".to_owned())); + } + let r = run("smlib_split_body_by_sheet", |k| { + super::dump::input(k, "brep", &body.0.lock().unwrap_or_else(PoisonError::into_inner)); + { + let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner); + super::dump::input(k, "sheet", &g); + if !is_sheet(k, &g)? || face >= g.face_list().len() { + return Ok(Err(Error::Argument)); + } + } + both_sides(k, body, true, |k, flip| { + let mut copy = { + let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner); + brep_copy(k, &g)? + }; + let Some(&f) = brep_p1::faces(©, None)?.get(face) else { return Ok(Err(Error::Argument)) }; + // The UV swaps notify as SMLib's objects do: through the kernel's callbacks, with + // the sheet's own attributes, so its faces' tags see each swap. + let turned = with_kernel_callbacks(k, &mut copy, |k, b, env| orient_sheet_surfaces(k, b, Some(f), !flip, env)); + copy.refresh_cached_topology(); + Ok(match turned { + Ok(()) => Ok(copy), + Err(e) => Err(status(e)), + }) + }) + })??; + Ok(r.map(|(b, f)| (Body::new(b), Body::new(f)))) +} diff --git a/crates/smlib/src/core/split_tests.rs b/crates/smlib/src/core/split_tests.rs new file mode 100644 index 0000000000..f2c5c0de65 --- /dev/null +++ b/crates/smlib/src/core/split_tests.rs @@ -0,0 +1,429 @@ +//! Split Face and Split Body on solidcore, against closed forms: face, edge and vertex counts, +//! volumes, how many faces each chosen face became, and every result exports to a record that +//! validates. With SMLib built, the same input converted to the C++ kernel and the same tools +//! give the same result or the same error (parity only: the expected values are the analytic +//! ones). +//! +//! The body is a 10 mm cube [0, 10]^3 on solidcore; the tools are described as +//! [`crate::ssi::Surface`] describes them. + +use super::split::{split_body, split_body_by_sheet, split_faces}; +use super::{Body, area, counts, make_box, to_arrays, volume}; +use crate::Error; +use crate::export::native; +use crate::ssi::Spec; + +const X: [f64; 3] = [1.0, 0.0, 0.0]; +const Y: [f64; 3] = [0.0, 1.0, 0.0]; +const Z: [f64; 3] = [0.0, 0.0, 1.0]; + +fn cube() -> Body { + make_box([0.0; 3], [10.0; 3]).unwrap() +} + +/// The plane x = `at` over u (along y) and v (along z) in `[-1, 11]`: it reaches over the cube. +fn plane_x(at: f64) -> Spec { + Spec::Plane { origin: [at, 0.0, 0.0], xaxis: Y, yaxis: Z, u: [-1.0, 11.0], v: [-1.0, 11.0] } +} + +/// The cylinder of radius 3 about the vertical line through (5, 5), from z = -1 to 11. +fn cylinder() -> Spec { + Spec::Cylinder { origin: [5.0, 5.0, 0.0], xaxis: X, yaxis: Y, radius: 3.0, angle_deg: [0.0, 360.0], height: [-1.0, 11.0] } +} + +#[derive(Clone, Debug)] +enum Op { + /// Split the faces (all six when empty) by the tools. + Faces(Vec, Vec), + Body(Spec), +} + +#[derive(Clone, Debug, PartialEq)] +enum Expect { + /// Volume, counts, and the total of faces made. + Split(f64, [usize; 3], usize), + /// The two sides' volumes and counts. + Halves(f64, [usize; 3], f64, [usize; 3]), + NoCut, +} + +fn case(name: &str) -> (Op, Expect) { + let all: Vec = (0..6).collect(); + match name { + // Four faces cross x = 5, each cut in two; the four edges along x are cut at it. + "split_faces_by_a_plane" => (Op::Faces(all, vec![plane_x(5.0)]), Expect::Split(1000.0, [10, 20, 12], 4)), + // The cylinder meets the top and the bottom in a circle each: a disc and a holed face. + "split_faces_by_a_cylinder" => (Op::Faces(all, vec![cylinder()]), Expect::Split(1000.0, [8, 14, 10], 2)), + // Two planes in turn, the second cutting the first's pieces. + "split_faces_by_two_planes" => (Op::Faces(all, vec![plane_x(3.0), plane_x(7.0)]), Expect::Split(1000.0, [14, 28, 16], 8)), + "split_faces_missed_by_the_tool" => (Op::Faces(all, vec![plane_x(20.0)]), Expect::NoCut), + "split_body_by_a_plane" => (Op::Body(plane_x(3.0)), Expect::Halves(300.0, [6, 12, 8], 700.0, [6, 12, 8])), + // A tool that misses leaves both parts the whole body. + "split_body_missed_by_the_tool" => (Op::Body(plane_x(20.0)), Expect::Halves(1000.0, [6, 12, 8], 1000.0, [6, 12, 8])), + _ => panic!("no case {name}"), + } +} + +#[derive(Debug, PartialEq)] +enum Measured { + Split { volume: f64, counts: [usize; 3], made: Vec }, + Halves { volumes: [f64; 2], counts: [[usize; 3]; 2] }, + NoCut, +} + +type Got = std::result::Result; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +fn valid(name: &str, b: &Body) { + let (rec, _) = native(&to_arrays(b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); +} + +fn judge(name: &str, who: &str, got: Got) -> Got { + let (_, expect) = case(name); + match (&expect, &got) { + (_, Err(g)) => panic!("{name}: {who} failed: {g}"), + (Expect::NoCut, Ok(m)) => assert_eq!(*m, Measured::NoCut, "{name}: {who}"), + (Expect::Split(v, n, total), Ok(Measured::Split { volume, counts, made })) => { + assert!(rel(*volume, *v) < 1e-9, "{name}: {who} volume {volume} vs analytic {v}"); + assert_eq!(counts, n, "{name}: {who} counts"); + assert_eq!(made.iter().sum::(), *total, "{name}: {who} faces made {made:?}"); + } + (Expect::Halves(v0, n0, v1, n1), Ok(Measured::Halves { volumes, counts })) => { + assert!(rel(volumes[0], *v0) < 1e-9 && rel(volumes[1], *v1) < 1e-9, "{name}: {who} volumes {volumes:?} vs {v0} {v1}"); + assert_eq!(counts, &[*n0, *n1], "{name}: {who} counts"); + } + (e, Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"), + } + got +} + +fn solidcore_case(name: &str) -> Got { + let (op, _) = case(name); + let got = match &op { + Op::Faces(faces, tools) => { + let tools: Vec<&Spec> = tools.iter().collect(); + split_faces(&cube(), faces, &tools).map(|r| match r { + None => Measured::NoCut, + Some((b, made)) => { + valid(name, &b); + Measured::Split { volume: volume(&b, 1e-8).unwrap(), counts: counts(&b).unwrap(), made } + } + }) + } + Op::Body(tool) => split_body(&cube(), tool).map(|r| match r { + None => Measured::NoCut, + Some((b, f)) => { + valid(name, &b); + valid(name, &f); + Measured::Halves { + volumes: [volume(&b, 1e-8).unwrap(), volume(&f, 1e-8).unwrap()], + counts: [counts(&b).unwrap(), counts(&f).unwrap()], + } + } + }), + }; + judge(name, "solidcore", got) +} + +/// The C++ kernel's surface for `spec`. +#[cfg(feature = "native")] +fn surface(spec: &Spec) -> crate::ssi::Surface { + use crate::ssi::Surface; + match spec { + Spec::Plane { origin, xaxis, yaxis, u, v } => Surface::plane(*origin, *xaxis, *yaxis, *u, *v), + Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => { + Surface::cylinder(*origin, *xaxis, *yaxis, *radius, *angle_deg, *height) + } + _ => panic!("no such tool in these cases"), + } + .unwrap() +} + +/// The case on the C++ kernel, called directly (whatever the choice), on the cube converted +/// from solidcore's. +#[cfg(feature = "native")] +fn cpp_case(name: &str) -> Got { + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let (op, _) = case(name); + let body = Brep::made_core(cube()); + let measure = |h: &ffi::Handle| { + let (rec, _) = native(&Brep::made(ffi::copy(h).unwrap()).cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + (ffi::volume(h, 1e-8).unwrap(), ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap())) + }; + let got = match &op { + Op::Faces(faces, tools) => { + let made: Vec = tools.iter().map(surface).collect(); + let handles: Vec<&ffi::ssi::SurfaceHandle> = made.iter().map(|s| s.handle().unwrap()).collect(); + let faces: Vec = faces.iter().map(|&f| f as i64).collect(); + match ffi::split::split_faces(body.cpp().unwrap(), &faces, &handles) { + Ok((h, made)) => { + let (volume, counts) = measure(&h); + Ok(Measured::Split { volume, counts, made: made.into_iter().map(|m| usize::try_from(m).unwrap()).collect() }) + } + Err(ffi::ops::ERR_NO_CUT) => Ok(Measured::NoCut), + Err(s) => Err(error(s)), + } + } + Op::Body(tool) => match ffi::split::split_body(body.cpp().unwrap(), surface(tool).handle().unwrap()) { + Ok((b, f)) => { + let (vb, nb) = measure(&b); + let (vf, nf) = measure(&f); + Ok(Measured::Halves { volumes: [vb, vf], counts: [nb, nf] }) + } + Err(ffi::ops::ERR_NO_CUT) => Ok(Measured::NoCut), + Err(s) => Err(error(s)), + }, + }; + judge(name, "C++", got) +} + +#[cfg(feature = "native")] +fn parity(name: &str) { + match (solidcore_case(name), cpp_case(name)) { + (Ok(Measured::Split { volume: a, counts: na, made: ma }), Ok(Measured::Split { volume: b, counts: nb, made: mb })) => { + assert!(rel(a, b) < 1e-9, "{name}: solidcore {a} vs C++ {b}"); + assert_eq!(na, nb, "{name}: counts (solidcore vs C++)"); + assert_eq!(ma, mb, "{name}: faces made per face (solidcore vs C++)"); + } + (Ok(Measured::Halves { volumes: a, counts: na }), Ok(Measured::Halves { volumes: b, counts: nb })) => { + assert!(rel(a[0], b[0]) < 1e-9 && rel(a[1], b[1]) < 1e-9, "{name}: solidcore {a:?} vs C++ {b:?}"); + assert_eq!(na, nb, "{name}: counts (solidcore vs C++)"); + } + (a, b) => assert_eq!(a, b, "{name}: solidcore vs C++"), + } +} + +macro_rules! cases { + ($($m:ident $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::solidcore_case(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::cpp_case(stringify!($m)); + } + } + )*}; +} + +cases!( + split_faces_by_a_plane, + split_faces_by_a_cylinder, + split_faces_by_two_planes, + split_faces_missed_by_the_tool, + split_body_by_a_plane, + split_body_missed_by_the_tool, +); + +/// The capi's argument checks, before any kernel call. +#[test] +fn hostile_arguments_are_refused() { + let b = cube(); + let p = plane_x(5.0); + assert_eq!(split_faces(&b, &[], &[&p]).err(), Some(Error::Argument), "no face"); + assert_eq!(split_faces(&b, &[0], &[]).err(), Some(Error::Argument), "no tool"); + assert_eq!(split_faces(&b, &[0, 0], &[&p]).err(), Some(Error::Argument), "a face twice"); + assert_eq!(split_faces(&b, &[6], &[&p]).err(), Some(Error::Argument), "no face 6"); + assert_eq!(split_faces(&b, &[usize::MAX], &[&p]).err(), Some(Error::Argument), "no face usize::MAX"); + let many = vec![&p; super::split::MAX_TOOLS + 1]; + assert_eq!(split_faces(&b, &[0], &many).err(), Some(Error::Argument), "too many tools"); + let faces: Vec = (0..=super::split::MAX_FACES).collect(); + assert_eq!(split_faces(&b, &faces, &[&p]).err(), Some(Error::Argument), "too many faces"); +} + +/// A sheet is no solid to split in two (`SMLIB_ERR_NOT_SOLID`). +#[test] +fn a_sheet_is_not_split_into_two_bodies() { + use solidcore::curve::curve::Curve; + use solidcore::geom::vector3d::Vec3; + use solidcore::primitive::api_curves::create_line_segment; + use solidcore::sweep::api_brep::create_linear_sweep; + let sheet = super::run("sheet", |k| { + let c = Curve::from(create_line_segment(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 0.0, 0.0))?); + create_linear_sweep(k, &[Some(&c)], Vec3::new(0.0, 1.0, 0.0), 10.0, false) + }) + .map(Body::new) + .unwrap(); + assert!(rel(area(&sheet, 1e-8).unwrap(), 100.0) < 1e-9); + assert_eq!(split_body(&sheet, &plane_x(5.0)).err(), Some(Error::NotSolid)); +} + +/// Faces of the box -20..5 x -20..20 x -20..5 copied off it into a sheet (`CopyFaces` with merge, +/// as the capi's test): the top z = 5 and, with `side`, the side x = 5 too. +fn lid(side: bool) -> Body { + use solidcore::EntityRef; + use solidcore::geom::tol::USE_DEFAULT; + use solidcore::query::api_queries::face_internal_point; + use solidcore::topology::brep::p1::faces; + use solidcore::topology::brep::p2::ArenaNotifyState; + use solidcore::topology::brep::p3::new_brep; + use solidcore::topology::brep::p7::copy_faces; + let slab = make_box([-20.0; 3], [25.0, 40.0, 25.0]).unwrap(); + super::run("sheet", |k| { + let mut whole = solidcore::topology::brep::p3::brep_copy(k, &slab.0.lock().unwrap())?; + let mut picked = Vec::new(); + for f in faces(&whole, None)? { + let p = face_internal_point(k, &mut whole, f)?; + if (p.z - 5.0).abs() < 1e-9 || (side && (p.x - 5.0).abs() < 1e-9) { + picked.push(f); + } + } + assert_eq!(picked.len(), if side { 2 } else { 1 }); + let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + w.set_tolerance(whole.tolerance()); + { + let mut state = ArenaNotifyState::new(&w); + let mut env = state.env(None); + let key_of = env.key_of; + let this_face = |f| key_of(EntityRef::Face(f)); + copy_faces(k, &mut whole, &picked, &mut w, true, &this_face, &mut env)?; + } + w.refresh_cached_topology(); + Ok(w) + }) + .map(Body::new) + .unwrap() +} + +/// The capi's test of `smlib_split_body_by_sheet` (fork 2fc375f): a cube 0..10 cut by an L of two +/// faces: the corner x < 5, z < 5 (250) on one side, the rest (750) on the other, whichever face +/// the sides are read from; on C++ too. +#[test] +fn split_body_by_an_l_sheet() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + let (c, sheet) = (cube(), lid(true)); + for face in 0..2 { + let (b, f) = split_body_by_sheet(&c, &sheet, face).unwrap().expect("the sheet divides the cube"); + valid("L sheet", &b); + valid("L sheet", &f); + let (vb, vf) = (volume(&b, 1e-8).unwrap(), volume(&f, 1e-8).unwrap()); + assert!(rel(vb.min(vf), 250.0) < 1e-9 && rel(vb.max(vf), 750.0) < 1e-9, "face {face}: {vb} + {vf}"); + #[cfg(feature = "native")] + { + let (body, tool) = (crate::Brep::made_core(cube()), crate::Brep::made_core(lid(true))); + let (bc, fc) = crate::ffi::split::split_body_by_sheet(body.cpp().unwrap(), tool.cpp().unwrap(), face as i64).unwrap(); + let (vbc, vfc) = (crate::ffi::volume(&bc, 1e-8).unwrap(), crate::ffi::volume(&fc, 1e-8).unwrap()); + assert!(rel(vb, vbc) < 1e-9 && rel(vf, vfc) < 1e-9, "face {face}: {vb} + {vf} vs C++ {vbc} + {vfc}"); + } + } +} + +/// What `smlib_split_body_by_sheet` refuses: a body that is no sheet, a face out of range, the +/// sheet as its own body; a face that ends inside the cube only nicks it, which is no cut. +#[test] +fn split_by_a_sheet_refuses() { + let (c, sheet) = (cube(), lid(true)); + assert_eq!(split_body_by_sheet(&c, &make_box([-20.0; 3], [25.0, 40.0, 25.0]).unwrap(), 0).err(), Some(Error::Argument)); + assert_eq!(split_body_by_sheet(&c, &sheet, 2).err(), Some(Error::Argument)); + assert_eq!(split_body_by_sheet(&c, &c, 0).err(), Some(Error::Argument)); + assert!(split_body_by_sheet(&c, &lid(false), 0).unwrap().is_none()); + assert_eq!(split_body_by_sheet(&sheet, &lid(false), 0).err(), Some(Error::NotSolid)); +} + +/// The body split is never changed. +#[test] +fn the_body_stays_as_it_was() { + let b = cube(); + let _ = split_faces(&b, &[0, 1, 2, 3, 4, 5], &[&plane_x(5.0)]).unwrap(); + let _ = split_body(&b, &plane_x(5.0)).unwrap(); + assert_eq!(counts(&b).unwrap(), [6, 12, 8]); + assert!(rel(volume(&b, 1e-8).unwrap(), 1000.0) < 1e-12); +} + +/// Face and edge tags (`Brep::tagged`) through a face split on solidcore, as on C++. +#[cfg(feature = "native")] +mod history { + use crate::{Brep, ffi}; + + #[test] + fn tags_cross_a_face_split() { + let _cpp = crate::route::on_cpp(); + crate::init().unwrap(); + let a = Brep::made_core(super::cube()).tagged(0).unwrap(); + let p = super::plane_x(5.0); + let s = super::surface(&p); + let faces = [0i64, 1, 2, 3, 4, 5]; + let cpp = Brep::made(ffi::split::split_faces(a.cpp().unwrap(), &faces, &[s.handle().unwrap()]).unwrap().0).history().unwrap(); + let core = super::split_faces(a.core().unwrap(), &[0, 1, 2, 3, 4, 5], &[&p]).unwrap().unwrap().0; + let core = Brep::made_core(core).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } +} + +/// A one-face sheet on a B-spline surface (as SolidCraft's extrude of a sketch spline makes it: +/// the cubic through (0, 0), (13, -8), (27, 8), (40, 0) in x and z, straight along y over +/// [0, 40]) cut by four plane strips standing on the square [12, 28]^2: the strips meet the +/// face inside its bounds, so the face's surface is intersected through its face's trimmed +/// cache. The square comes out as a face of its own, the sheet holed by it. +#[test] +fn a_spline_sheet_is_cut_by_strips_inside_it() { + use solidcore::geom::tol::USE_DEFAULT; + use solidcore::geom::vector3d::Vec3; + use solidcore::nurbs::b_spline_curve::p1::KnotType; + use solidcore::nurbs::b_spline_surface::p1::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData}; + use solidcore::surface::surface::Surface; + use solidcore::topology::brep::p3::new_brep; + use solidcore::topology::brep::p8::create_face_from_surface; + // Control points u-major: the curve's four, each at y = 0 and y = 40. + let curve = [[0.0, 0.0], [13.0, -8.0], [27.0, 8.0], [40.0, 0.0]]; + let pts: Vec = curve.iter().flat_map(|[x, z]| [Vec3::new(*x, 0.0, *z), Vec3::new(*x, 40.0, *z)]).collect(); + let sheet = super::run("sheet", |k| { + let surface = Surface::from(BSplineSurface::create_canonical(&CanonicalSurfaceData { + u_degree: 3, + v_degree: 1, + control_points: &pts, + form: BSplineSurfaceForm::Unspecified, + u_multiplicities: &[4, 4], + v_multiplicities: &[2, 2], + u_knots: &[0.0, 1.0], + v_knots: &[0.0, 40.0], + knot_type: KnotType::Unspecified, + weights: None, + uv_domain: None, + })?); + let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + let natural = surface.natural_uv_domain_with_nurb(k)?; + super::sweep::build_faces(&mut b, |s, fenv| create_face_from_surface(k, s, Some(surface), &natural, fenv))?; + b.refresh_cached_topology(); + Ok(b) + }) + .map(Body::new) + .unwrap(); + assert_eq!(counts(&sheet).unwrap(), [1, 4, 4]); + let whole = area(&sheet, 1e-8).unwrap(); + // The strip standing on the line from `o` along `d` (16 long), reaching past the sheet in z. + let strip = |o: [f64; 3], d: [f64; 3]| Spec::Plane { origin: o, xaxis: Z, yaxis: d, u: [-60.0, 60.0], v: [0.0, 16.0] }; + let tools = [ + strip([12.0, 12.0, 0.0], X), + strip([28.0, 12.0, 0.0], Y), + strip([28.0, 28.0, 0.0], [-1.0, 0.0, 0.0]), + strip([12.0, 28.0, 0.0], [0.0, -1.0, 0.0]), + ]; + let tools: Vec<&Spec> = tools.iter().collect(); + let (cut, made) = split_faces(&sheet, &[0], &tools).unwrap().expect("the strips cut the sheet"); + valid("spline sheet", &cut); + // The square's face and the holed sheet: four edges and vertices round each. + assert_eq!(counts(&cut).unwrap(), [2, 8, 8]); + assert_eq!(made, [1]); + // Cutting moves no surface: the pieces cover the sheet. + assert!(rel(area(&cut, 1e-8).unwrap(), whole) < 1e-9); +} diff --git a/crates/smlib/src/core/ssi.rs b/crates/smlib/src/core/ssi.rs new file mode 100644 index 0000000000..f24b3f95f6 --- /dev/null +++ b/crates/smlib/src/core/ssi.rs @@ -0,0 +1,121 @@ +//! Surface/surface intersection on solidcore (`smlib_ssi.cpp`'s `smlib_ssi`, `smlib_surface_domain`, +//! `smlib_surface_evaluate`), on the surfaces [`crate::core::split`] makes from a [`Spec`]: +//! `SmSurface::GlobalSurfaceIntersect` and `SmAdvSurfaceIntersector`, whose results are read as the +//! capi reads them. + +use std::borrow::Cow; + +use solidcore::curve::curve::Curve; +use solidcore::geom::vector2d::Point2; +use solidcore::intersect::surface_intersector::p5::do_intersection; +use solidcore::kernel::Kernel; +use solidcore::nurbs::b_spline_curve::BSplineCurve; +use solidcore::surface::surface::p2::{SurfaceIntersection, TsectCurveType}; + +use super::convert::bspline_curve_part; +use super::run; +use super::split::{Tool, tool}; +use crate::ffi::ssi::Nurbs; +use crate::ssi::{CurveType, Method, Spec, SsiCurve, SsiPoint, SsiResult, curve2, curve3}; +use crate::{Error, Result}; + +/// The surface of `spec` with its natural UV domain (`construct`). +pub(crate) fn surface(spec: &Spec) -> Result { + run("smlib_surface", |k| tool(k, spec))? +} + +/// `[u0, u1, v0, v1]`. +pub(crate) fn domain(s: &Tool) -> [f64; 4] { + let (lo, hi) = (s.domain.min(), s.domain.max()); + [lo.x, hi.x, lo.y, hi.y] +} + +/// The surface's point at (u, v): `smlib_surface_evaluate`, a non-finite point being `SM_ERR`. +pub(crate) fn evaluate(s: &Tool, u: f64, v: f64) -> Result<[f64; 3]> { + if !u.is_finite() || !v.is_finite() { + return Err(Error::Argument); + } + let p = run("smlib_surface_evaluate", |k| s.surface.evaluate_point(k, Point2::new(u, v)))?; + let r = [p.x, p.y, p.z]; + if r.iter().all(|x| x.is_finite()) { Ok(r) } else { Err(Error::Solidcore("smlib_surface_evaluate: not a finite point".into())) } +} + +fn curve_type(t: TsectCurveType) -> CurveType { + match t { + TsectCurveType::Unknown => CurveType::Unknown, + TsectCurveType::Touching => CurveType::Touching, + TsectCurveType::Crossing => CurveType::Crossing, + TsectCurveType::Tangent => CurveType::Tangent, + TsectCurveType::Coincident => CurveType::Coincident, + TsectCurveType::NearTangent => CurveType::NearTangent, + TsectCurveType::RegionBoundary => CurveType::RegionBoundary, + } +} + +/// The capi's `nurbs`: `c` as its B-spline, control points made cartesian in `dim` coordinates; +/// `None` for a curve that is not one, which is never approximated here. +fn nurbs(k: &Kernel, c: &Curve, dim: usize) -> Result> { + let Some(part): Option> = bspline_curve_part(k, c)? else { return Ok(None) }; + let Some(n) = part.gw_nurb() else { return Ok(None) }; + let mut out = Nurbs { points: Vec::with_capacity(n.cpts().len() * dim), weights: Vec::with_capacity(n.cpts().len()), knots: n.knots().to_vec() }; + for p in n.cpts() { + let w = if p.w != solidcore::nurbs::math::NOW { p.w } else { 1.0 }; + let q = [p.x / w, p.y / w, p.z / w]; + out.points.extend_from_slice(q.get(..dim).unwrap_or(&q)); + out.weights.push(w); + } + Ok(Some(out)) +} + +/// The capi's `start`: where `c` starts, NaN where there is no curve or it can't be evaluated. +fn start(k: &Kernel, c: Option<&Curve>) -> [f64; 3] { + c.and_then(|c| c.evaluate_point(k, c.natural_interval().min()).ok()).map_or([f64::NAN; 3], |p| [p.x, p.y, p.z]) +} + +fn read(k: &Kernel, r: &SurfaceIntersection) -> Result { + let mut out = SsiResult::default(); + for (i, c3) in r.curves_3d.iter().enumerate() { + let (ua, ub) = (r.surface1_uv_curves.get(i).and_then(Option::as_ref), r.surface2_uv_curves.get(i).and_then(Option::as_ref)); + let ty = r.curve_types.get(i).copied(); + let ivl = c3.natural_interval(); + let uv = |c: Option<&Curve>| -> Result<_> { Ok(c.map(|c| nurbs(k, c, 2)).transpose()?.flatten().map(curve2)) }; + out.curves.push(SsiCurve { + curve_type: ty.map_or(CurveType::None, curve_type), + deviation: r.deviations.get(i).copied().unwrap_or(f64::NAN), + interval: [ivl.min(), ivl.max()], + // A curve that is no B-spline is the one thing the result can't hold. + curve: curve3(nurbs(k, c3, 3)?.ok_or(Error::Unsupported)?), + uv_a: uv(ua)?, + uv_b: uv(ub)?, + }); + if ty == Some(TsectCurveType::Touching) { + let (a, b) = (start(k, ua), start(k, ub)); + out.points.push(SsiPoint { xyz: start(k, Some(c3)), uv_a: [a[0], a[1]], uv_b: [b[0], b[1]] }); + } + } + Ok(out) +} + +/// `smlib_ssi`: `a` and `b` within their domains, at approximation tolerance `approx_tol` (<= 0: +/// the kernel's default). +pub(crate) fn intersect(a: &Tool, b: &Tool, method: Method, approx_tol: f64) -> Result { + if !approx_tol.is_finite() { + return Err(Error::Argument); + } + let tol = (approx_tol > 0.0).then_some(approx_tol); + let edges = [true, true]; + run("smlib_ssi", |k| { + let found = match method { + Method::Global => a.surface.global_surface_intersect(k, &a.domain, &b.surface, &b.domain, edges, tol, None), + Method::Advanced => do_intersection(k, &a.surface, &a.domain, &b.surface, &b.domain, edges, tol, None), + }; + // An SMLib status is an answer, as the C API returns it. + match found { + Ok(found) => Ok(read(k, &found)), + Err(e) => match e.sm_status() { + Some(status) => Ok(Err(Error::Status(status))), + None => Err(e), + }, + } + })? +} diff --git a/crates/smlib/src/core/sweep.rs b/crates/smlib/src/core/sweep.rs new file mode 100644 index 0000000000..4f02388baf --- /dev/null +++ b/crates/smlib/src/core/sweep.rs @@ -0,0 +1,1726 @@ +//! Extrusions, revolutions, sweeps, lofts and planar faces on solidcore: the C API's logic round +//! SM_API's sweeps, ported from the fork's `capi/src/smlib_sweeps.cpp` (`coord_ok`, `unit`, +//! `line`, `arc`, `nurbs`, `parse`, `finish`, `one_loop`, `FlatArc`, `one_circle`, `angle_on`, +//! `one_wall_per_circle`, `straight`, `circular`, `joined`, +//! `put_motion`, `turned`, `Rigid`, `Segment`, `segmented`, `carried`, `piecewise`, `maps_ok`, `placed`, `put_curve`, and the entry points +//! `smlib_planar_faces`, `smlib_extrude`, `smlib_revolve`, `smlib_sweep_placed`, `smlib_loft`, +//! `smlib_loft_to_point`, `smlib_sweep_stations`, `smlib_project_curves`, `smlib_face_section`, +//! and `smlib_screw_sweep` and `smlib_screw_sweep_radial` with `Screw`, `Stations`, +//! `lift_error`, `offset_error`, `path`, `path_curve`, `axial_radius`, `cylinder_piece`, +//! `screw_wall` and `screw_sweep`). +//! +//! As there, each call builds its own curves from the curve list, checks its numbers before the +//! kernel sees them, runs SM_API's sweep and hands the result over only when it is what was asked +//! for: a closed manifold solid when the ends are capped ([`Error::NotSolid`] otherwise), never a +//! body without faces ([`Error::Empty`]). +//! +//! The screw sweeps are the capi's own: it builds their walls from SMLib's B-spline surfaces, +//! cylinders and face builders (`Stations`, `screw_wall`, `cylinder_piece`), with no SM_API +//! sweep behind them, and solidcore leaves them to the capi (solidcore PR #1326, closed as +//! fork-only; #1162 and #1163 triaged not applicable). So they are ported here. + +use std::f64::consts::{PI, TAU}; + +use solidcore::curve::curve::p3::compute_crvs_normal; +use solidcore::curve::curve::{Curve, CurveKind}; +use solidcore::curve::ellipse::Ellipse; +use solidcore::curve::line::Line; +use solidcore::geom::axis2_placement::Axis2Placement; +use solidcore::geom::extent1d::Extent1d; +use solidcore::geom::tol::{SmallTopoType, USE_DEFAULT, context_zone_tol_3d}; +use solidcore::geom::vector3d::{Point3, Vec3}; +use solidcore::heal::stitch::p1::api_stitch_into_solid; +use solidcore::kernel::Kernel; +use solidcore::nurbs::b_spline_curve::{BSplineCurve, BSplineCurveForm, CanonicalData, CurveCanonical, KnotType}; +use solidcore::nurbs::b_spline_surface::{BSplineSurface, BSplineSurfaceForm, CanonicalSurfaceData}; +use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType; +use solidcore::primitive::primitive_creation::SweepOptions; +use solidcore::surface::cone::Cone; +use solidcore::surface::surface::Surface; +use solidcore::sweep::api_brep::{ + CornerRefusal, OffsetCornerType, TaperCornerRefusal, TaperExtrude, create_curve_sweep_with_placements, create_linear_sweep, + create_rotational_sweep, create_taper_extrude_with_corner, +}; +use solidcore::sweep::api_surfaces::create_skin_primitive; +use solidcore::topology::brep::p1 as brep_p1; +use solidcore::topology::brep::p1::{ParallelProjectionOptions, create_parallel_projection_curves}; +use solidcore::topology::brep::p2::{ArenaNotifyState, is_manifold_solid}; +use solidcore::topology::brep::p3::new_brep; +use solidcore::topology::brep::p4::stitch_faces; +use solidcore::topology::brep::p5::{StitchAndOrientOptions, stitch_and_orient}; +use solidcore::topology::brep::p7::copy_faces; +use solidcore::topology::brep::p8::{ + FaceBuildEnv, create_face_from_surface, create_faces_with_curves, create_planar_face_with_3d_curves, create_planar_faces_with_3d_curves, +}; +use solidcore::topology::brep::planar_section::{PlanarSectionOptions, SectionOutputs, create_planar_section_curves}; +use solidcore::topology::topology_traverser::Marks; +use solidcore::topology::{Brep, FaceId}; +use solidcore::{EntityRef, SolidError}; + +use super::blend::status; +use super::offset::{face_miss, pick_faces}; +use super::{Body, run, with_body}; +use crate::sweeps::{Affine, Curve as Profile, DraftCorner, Ends, MAX_CURVES, MAX_STATION_CURVES, MAX_STATIONS, SweepFrame}; +use crate::{Error, Result, ffi}; + +/// The capi's statuses as SolidCraft errors: an operation's own result. +type Out = std::result::Result; + +/// A coordinate no larger than this (mm, 1000 km), so that SMLib's tolerances stay meaningful. +const MAX_COORD: f64 = 1e9; + +/// `SMLIB_FLAT_MAX_LEN`: most reals a curve list may hold. +const FLAT_MAX_LEN: usize = 1 << 27; + +/// `SMLIB_SCREW_MAX_TURNS`. +const SCREW_MAX_TURNS: f64 = 200.0; + +/// `SMLIB_SCREW_MAX_LAW`. +const SCREW_MAX_LAW: usize = 1024; + +/// `coord_ok`. +fn coord_ok(p: &[f64]) -> bool { + p.iter().all(|x| x.is_finite() && x.abs() <= MAX_COORD) +} + +fn v3(p: [f64; 3]) -> Vec3 { + Vec3::new(p[0], p[1], p[2]) +} + +/// `unit`: a unit vector from `v`, or `None` when it has no usable length. +fn unit(v: [f64; 3]) -> Option { + if !v.iter().all(|x| x.is_finite()) { + return None; + } + let u = v3(v); + let n = u.length(); + (n > 1e-12 && n.is_finite()).then(|| u * (1.0 / n)) +} + +/// An SMLib call's own status passed on (`status(...)` in the capi), else the value. +fn called(r: solidcore::Result) -> Out { + r.map_err(status) +} + +/// `line`. +fn line(start: [f64; 3], end: [f64; 3]) -> Out { + if !coord_ok(&start) || !coord_ok(&end) { + return Err(Error::Argument); + } + let (a, b) = (v3(start), v3(end)); + if !((b - a).length() > 1e-9) { + return Err(Error::Argument); + } + called(Line::create_line_segment(3, a, b, None)).map(Curve::from) +} + +/// `arc`: centre, unit axis, unit reference direction (square to the axis), radius, start and +/// end angles (radians, counter-clockwise about the axis from the reference). +fn arc(k: &Kernel, center: [f64; 3], axis: [f64; 3], reference: [f64; 3], radius: f64, start: f64, end: f64) -> Out { + if !coord_ok(¢er) || ![radius, start, end].iter().all(|x| x.is_finite()) { + return Err(Error::Argument); + } + let (Some(axis), Some(r)) = (unit(axis), unit(reference)) else { return Err(Error::Argument) }; + if axis.dot(r).abs() > 1e-9 { + return Err(Error::Argument); + } + let sweep = end - start; + if !(radius > 1e-9 && radius < MAX_COORD) || start.abs() > 1e6 || !(sweep > 1e-9 && sweep <= TAU * (1.0 + 1e-12)) { + return Err(Error::Argument); + } + // The circle's own parameter starts at the arc's start: SMLib takes intervals in degrees + // within [-360, 360]. + let y = axis.cross(r); + let x0 = r * start.cos() + y * start.sin(); + let y0 = axis.cross(x0); + let frame = Axis2Placement::new(v3(center), x0, y0); + let degrees = Extent1d::from_min_max(0.0, (sweep * 180.0 / PI).min(360.0)); + called(Ellipse::create_canonical_circle(k, &frame, radius, Some(°rees), None)).map(Curve::from) +} + +/// `nurbs`: control points (x, y, z, weight), then points + degree + 1 knots, clamped (end +/// multiplicity degree + 1, inner ones at most degree), non-decreasing. +fn nurbs(degree: usize, points: &[[f64; 4]], knots: &[f64]) -> Out { + let count = points.len(); + if !(1..=ffi::sweeps::CURVE_MAX_DEGREE).contains(°ree) || count < degree + 1 || count > ffi::sweeps::CURVE_MAX_POINTS { + return Err(Error::Argument); + } + let mut ps = Vec::with_capacity(count); + let mut weights = Vec::with_capacity(count); + let mut rational = false; + for p in points { + if !coord_ok(&p[..3]) || !p[3].is_finite() || !(p[3] > 1e-12 && p[3] < 1e12) { + return Err(Error::Argument); + } + ps.push(Vec3::new(p[0], p[1], p[2])); + weights.push(p[3]); + rational = rational || p[3] != 1.0; + } + if knots.len() != count + degree + 1 || !knots.iter().all(|x| x.is_finite()) { + return Err(Error::Argument); + } + let (mut unique, mut mult): (Vec, Vec) = (Vec::new(), Vec::new()); + for (i, &u) in knots.iter().enumerate() { + match (i.checked_sub(1).and_then(|j| knots.get(j)), mult.last_mut()) { + (Some(&prev), _) if u < prev => return Err(Error::Argument), + (Some(&prev), Some(m)) if u == prev => *m += 1, + _ => { + unique.push(u); + mult.push(1); + } + } + } + let (Some(&first), Some(&last), Some(&m0), Some(&m1)) = (unique.first(), unique.last(), mult.first(), mult.last()) else { + return Err(Error::Argument); + }; + if unique.len() < 2 || m0 != degree + 1 || m1 != degree + 1 || !(last - first > 1e-12) { + return Err(Error::Argument); + } + if mult.iter().skip(1).take(mult.len() - 2).any(|&m| m > degree) { + return Err(Error::Argument); + } + let data = CanonicalData { + dim: 3, + degree, + control_points: &ps, + form: BSplineCurveForm::Unspecified, + knot_multiplicities: &mult, + knots: &unique, + knot_type: KnotType::Unspecified, + weights: rational.then_some(weights.as_slice()), + trim: None, + }; + called(BSplineCurve::create_canonical(&data)).map(Curve::from) +} + +/// `parse`: the curves of a curve list. A list of none, more than [`MAX_CURVES`] or more reals +/// than `SMLIB_FLAT_MAX_LEN` is [`Error::Argument`]. +fn parse(k: &Kernel, curves: &[Profile]) -> Out> { + if curves.is_empty() || curves.len() > MAX_CURVES { + return Err(Error::Argument); + } + let mut reals = 0usize; + let mut out = Vec::with_capacity(curves.len()); + for c in curves { + let made = match c { + Profile::Line { start, end } => { + reals += 6; + line(*start, *end)? + } + Profile::Arc { center, axis, reference, radius, start, end } => { + reals += 12; + arc(k, *center, *axis, *reference, *radius, *start, *end)? + } + Profile::Nurbs { degree, points, knots } => { + // `parse` reads the kinds first: at least two points. + if *degree < 1 || *degree > ffi::sweeps::CURVE_MAX_DEGREE || points.len() < 2 || points.len() > ffi::sweeps::CURVE_MAX_POINTS { + return Err(Error::Argument); + } + reals = reals.saturating_add(4 * points.len() + points.len() + degree + 1); + if reals > FLAT_MAX_LEN || knots.len() != points.len() + degree + 1 { + return Err(Error::Argument); + } + nurbs(*degree, points, knots)? + } + }; + if reals > FLAT_MAX_LEN { + return Err(Error::Argument); + } + out.push(made); + } + Ok(out) +} + +/// `finish`: the result must have faces, and be a closed manifold solid when `solid`. +fn finish(b: Brep, solid: bool) -> solidcore::Result> { + if b.face_list().is_empty() { + return Ok(Err(Error::Empty)); + } + if solid && !is_manifold_solid(&b)? { + return Ok(Err(Error::NotSolid)); + } + Ok(Ok(b)) +} + +/// Both ends of `c` (`GetEnds`). +fn ends(k: &Kernel, c: &Curve) -> solidcore::Result<(Point3, Point3)> { + let e = c.ends(k)?; + Ok((e.start, e.end)) +} + +/// `one_loop`: whether the curves, in order, run end to end round one closed loop (within `tol`). +fn one_loop(k: &Kernel, curves: &[Curve], tol: f64) -> solidcore::Result { + let (mut first, mut prev) = (None, None); + for c in curves { + let (a, b) = ends(k, c)?; + match prev { + None => first = Some(a), + Some(p) if (a - p).length() > tol => return Ok(false), + Some(_) => {} + } + prev = Some(b); + } + Ok(matches!((first, prev), (Some(f), Some(p)) if (p - f).length() <= tol)) +} + +/// `FlatArc`: an arc of a curve list as numbers, its frame, radius and angles. +#[derive(Clone, Copy)] +struct FlatArc { + centre: Vec3, + axis: Vec3, + reference: Vec3, + radius: f64, + start: f64, + sweep: f64, +} + +impl FlatArc { + /// `flat_arc`: the arc's numbers, its axis and reference made unit (`parse` has checked + /// that they can be); `None` for a curve that is no arc. + fn of(c: &Profile) -> Option { + let Profile::Arc { center, axis, reference, radius, start, end } = c else { return None }; + Some(FlatArc { + centre: v3(*center), + axis: unit(*axis).unwrap_or_else(|| v3(*axis)), + reference: unit(*reference).unwrap_or_else(|| v3(*reference)), + radius: *radius, + start: *start, + sweep: end - start, + }) + } + + /// The point at angle `t` (radians from the reference, counter-clockwise about the axis). + fn at(&self, t: f64) -> Point3 { + let y = self.axis.cross(self.reference); + self.centre + self.reference * (self.radius * t.cos()) + y * (self.radius * t.sin()) + } + + /// `angle_on`: the angle of `p` on this arc's circle, in its frame (radians, in [0, 2π)). + fn angle_on(&self, p: Point3) -> f64 { + let d = p - self.centre; + let y = self.axis.cross(self.reference); + let t = d.dot(y).atan2(d.dot(self.reference)); + if t < 0.0 { t + TAU } else { t } + } +} + +/// `one_circle`: whether arcs `a` and `b` lie on one circle to `tol`: centres and radii within +/// it, and the axes alike (the same way round) to within it at the rim. +fn one_circle(a: &FlatArc, b: &FlatArc, tol: f64) -> bool { + (a.centre - b.centre).length() <= tol + && (a.radius - b.radius).abs() <= tol + && a.axis.dot(b.axis) > 0.0 + && a.axis.cross(b.axis).length() * a.radius.max(b.radius) <= tol +} + +/// `one_wall_per_circle` (tangency-labs/smlib 7dee48f): the profile `curves` (parsed from +/// `given`) with each run of consecutive arcs on one circle within a loop made one arc, so that +/// the run's wall is one face as a single arc's is: a circle drawn in pieces (split where another +/// sketch curve ends on it) sweeps the one cylinder it lies on, not a face per piece meeting the +/// next along a line that is no edge of the shape. A run that is its whole loop is the full +/// circle, swept as a plain circle is: one periodic face, its seam the one edge it uses twice. +/// The seam goes where a plain circle has it: when the arcs are pieces of one drawn circle (they +/// share its frame, the reference direction their angles are measured from), at that circle's +/// own start, angle 0 of that frame; otherwise at the middle of the run's longest arc (the first +/// of equal ones, in profile order). `tol` is the model's zone tolerance, within which +/// consecutive curves already meet. +fn one_wall_per_circle(k: &Kernel, given: &[Profile], curves: Vec, tol: f64) -> solidcore::Result>> { + let n = curves.len(); + if given.len() != n { + return Ok(Err(Error::Argument)); + } + let arcs: Vec> = given.iter().map(FlatArc::of).collect(); + let (mut head, mut tail) = (Vec::with_capacity(n), Vec::with_capacity(n)); + for c in &curves { + let (a, b) = ends(k, c)?; + head.push(a); + tail.push(b); + } + // Whether the end of curve `i` meets the start of curve `j`; whether arc `j` goes on from + // arc `i`, on its circle. + let meets = |i: usize, j: usize| matches!((tail.get(i), head.get(j)), (Some(t), Some(h)) if (*t - *h).length() <= tol); + let arc_of = |i: usize| arcs.get(i).copied().flatten(); + let joins = |i: usize, j: usize| match (arc_of(i), arc_of(j)) { + (Some(a), Some(b)) => meets(i, j) && one_circle(&a, &b, tol), + _ => false, + }; + let mut taken: Vec> = curves.into_iter().map(Some).collect(); + let mut take = |i: usize| taken.get_mut(i).and_then(Option::take).ok_or(Error::Argument); + let mut out = Vec::with_capacity(n); + let mut first = 0; + while first < n { + // The loop from `first`: its curves run end to end until one ends at its start. + let mut last = first; + while last + 1 < n && !meets(last, first) && meets(last, last + 1) { + last += 1; + } + let len = last - first + 1; + let closed = meets(last, first); + // Whether two of the loop's arcs go on on one circle. + let mut any = false; + let mut j = 0; + while j + 1 < len || (closed && len > 1 && j < len) { + any = any || joins(first + j, first + (j + 1) % len); + j += 1; + } + if !any { + for i in first..=last { + match take(i) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } + first = last + 1; + continue; + } + // A run starts where its curve does not go on from the one before it round the loop. + let mut begin = 0; + if closed { + while begin < len && joins(first + (begin + len - 1) % len, first + begin) { + begin += 1; + } + } + if begin == len { + // The whole loop is arcs of one circle: the full circle, its seam at the drawn + // circle's start when the arcs share its frame, else at the middle of the longest arc. + let Some(a) = arc_of(first) else { return Ok(Err(Error::Argument)) }; + let mut one_frame = true; + let mut longest = a; + for i in first..=last { + let Some(b) = arc_of(i) else { return Ok(Err(Error::Argument)) }; + one_frame = one_frame && (b.reference - a.reference).length() * a.radius <= tol; + if b.sweep > longest.sweep { + longest = b; + } + } + let seam = if one_frame { 0.0 } else { a.angle_on(longest.at(longest.start + 0.5 * longest.sweep)) }; + let p = |v: Vec3| [v.x, v.y, v.z]; + match arc(k, p(a.centre), p(a.axis), p(a.reference), a.radius, seam, seam + TAU) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } else { + let mut kk = 0; + while kk < len { + let i = first + (begin + kk) % len; + // The arcs i, i + 1, ... round the loop that go on on its circle. + let mut m = 1; + while kk + m < len && joins(first + (begin + kk + m - 1) % len, first + (begin + kk + m) % len) { + m += 1; + } + if m == 1 { + match take(i) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } else { + let (Some(a), Some(&h)) = (arc_of(i), head.get(i)) else { return Ok(Err(Error::Argument)) }; + let sweep: f64 = (0..m).filter_map(|j| arc_of(first + (begin + kk + j) % len)).map(|b| b.sweep).sum(); + let start = a.angle_on(h); + let p = |v: Vec3| [v.x, v.y, v.z]; + match arc(k, p(a.centre), p(a.axis), p(a.reference), a.radius, start, start + sweep) { + Ok(c) => out.push(c), + Err(e) => return Ok(Err(e)), + } + } + kk += m; + } + } + first = last + 1; + } + Ok(Ok(out)) +} + +/// Runs a call that makes a new body: the kernel locked, its own status as the capi passes it. +fn making(call: &'static str, f: impl FnOnce(&mut Kernel) -> solidcore::Result>) -> Result { + run(call, f)?.map(Body::new) +} + +/// `smlib_planar_faces`: a sheet of planar faces bounded by the closed `loops` +/// (`SmApiCreatePlanarFaces`: `CreatePlanarFacesWith3DCurves` into a new B-rep's infinite region). +pub(crate) fn planar_faces(loops: &[Profile]) -> Result { + making("smlib_planar_faces", |k| { + let curves = match parse(k, loops) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + let region = b.infinite_region(); + let made = { + let mut state = ArenaNotifyState::new(&b); + let mut env = state.env(None); + let key_of = env.key_of; + let this_face = |f: FaceId| key_of(EntityRef::Face(f)); + let mut marks = Marks::default(); + let mut fenv = FaceBuildEnv { env: &mut env, marks: &mut marks, this_face: &this_face }; + // (the tolerance is not used: CreatePlanarFacesWith3DCurves takes the B-rep's) + create_planar_faces_with_3d_curves(k, &mut b, region, &curves, 0.01, &mut fenv) + }; + if let Err(e) = made { + return Ok(Err(status(e))); + } + b.refresh_cached_topology(); + finish(b, false) + }) +} + +/// `smlib_extrude`: the planar `profile` extruded `distance` along `direction`, straight +/// (`SmApiCreateLinearSweep`) or drafted (`SmApiCreateTaperExtrude`). +pub(crate) fn extrude(profile: &[Profile], direction: [f64; 3], distance: f64, draft: f64, corner: DraftCorner, cap: bool) -> Result { + if !distance.is_finite() || !(distance > 1e-9 && distance < MAX_COORD) || !draft.is_finite() || draft.abs() >= PI / 2.0 - 1e-6 { + return Err(Error::Argument); + } + making("smlib_extrude", |k| { + let Some(dir) = unit(direction) else { return Ok(Err(Error::Argument)) }; + let curves = match parse(k, profile) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + if draft == 0.0 { + // The fork's capi (7dee48f): arcs of a loop on one circle sweep one wall. + let tol = context_zone_tol_3d(k, None, SmallTopoType::NormalSize); + let walls = match one_wall_per_circle(k, profile, curves, tol)? { + Ok(w) => w, + Err(e) => return Ok(Err(e)), + }; + let refs: Vec> = walls.iter().map(Some).collect(); + return match create_linear_sweep(k, &refs, dir, distance, cap) { + Ok(r) => finish(r, cap), + Err(e) => Ok(Err(status(e))), + }; + } + let refs: Vec> = curves.iter().map(Some).collect(); + // SMLib's draft extrusion takes a single chain of curves, square to the extrusion: the + // height's sign picks the side, matched to `direction` here. + if !one_loop(k, &curves, 1e-6)? { + return Ok(Err(Error::Argument)); + } + let Ok(normal) = compute_crvs_normal(k, &curves) else { return Ok(Err(Error::Argument)) }; + let along = normal.dot(dir); + if along.abs() < 1.0 - 1e-9 { + return Ok(Err(Error::Argument)); + } + let height = if along > 0.0 { distance } else { -distance }; + let angle = draft * 180.0 / PI; + // The fork's corner rules (54148db, e8f23e0): the curvature rule continues each wall as + // itself, the tangent rule a profile of lines and arcs as their tangent planes + // (`fixes.taper_tangent_corner_planes`), and a corner either cannot close is refused, + // with why and where (`SmTaperCornerRefusal`, `pOptRefusedCorner`), as the capi passes it on. + let rule = match corner { + DraftCorner::Curvature => OffsetCornerType::CurveExtension, + DraftCorner::Tangent => OffsetCornerType::LinearExtension, + DraftCorner::Round => OffsetCornerType::FilletCorner, + }; + match create_taper_extrude_with_corner(k, &refs, height, angle, if cap { 3 } else { 0 }, rule) { + Ok(TaperExtrude::Solid(r)) => finish(r, cap), + Ok(TaperExtrude::Refused(r)) => Ok(Err(refused(r))), + Err(e) => Ok(Err(status(e))), + } + }) +} + +/// A refused draft corner as the capi reports it: the reason's status with the corner's point, +/// [`Error::Corner`] when the point is finite and the status's own error otherwise. +fn refused(r: CornerRefusal) -> Error { + let (why, code) = match r.reason { + TaperCornerRefusal::Apart => (crate::sweeps::CornerRefusal::Apart, ffi::sweeps::ERR_CORNER_APART), + TaperCornerRefusal::WallCloses => (crate::sweeps::CornerRefusal::WallCloses, ffi::sweeps::ERR_WALL_CLOSES), + TaperCornerRefusal::NotExact => (crate::sweeps::CornerRefusal::NotExact, ffi::trim::ERR_NOT_EXACT), + }; + let at = [r.corner.x, r.corner.y, r.corner.z]; + if at.iter().all(|x| x.is_finite()) { Error::Corner { why, at } } else { crate::error(code) } +} + +/// `smlib_revolve`: the planar `profile` turned `angle` radians about the axis through `origin` +/// along `axis` (`SmApiCreateRotationalSweep`). +pub(crate) fn revolve(profile: &[Profile], origin: [f64; 3], axis: [f64; 3], angle: f64, cap: bool) -> Result { + if !coord_ok(&origin) || !angle.is_finite() || !(angle > 1e-9 && angle <= TAU * (1.0 + 1e-12)) { + return Err(Error::Argument); + } + making("smlib_revolve", |k| { + let Some(dir) = unit(axis) else { return Ok(Err(Error::Argument)) }; + let curves = match parse(k, profile) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + let refs: Vec> = curves.iter().map(Some).collect(); + let degrees = (angle * 180.0 / PI).min(360.0); + match create_rotational_sweep(k, &refs, v3(origin), dir, degrees, cap) { + Ok(r) => finish(r, cap), + Err(e) => Ok(Err(status(e))), + } + }) +} + +/// `straight`: whether the path is lines end to end along one direction; then that unit +/// direction and the path's length. +fn straight(k: &Kernel, path: &[Curve]) -> solidcore::Result> { + let (mut dir, mut distance, mut prev) = (None::, 0.0, None::); + for c in path { + if !matches!(c.kind(), CurveKind::Line(_)) { + return Ok(None); + } + let (a, b) = ends(k, c)?; + if prev.is_some_and(|p| (a - p).length() > 1e-9) { + return Ok(None); + } + prev = Some(b); + let d = b - a; + let l = d.length(); + if !(l > 1e-12) { + return Ok(None); + } + let d = d * (1.0 / l); + match dir { + None => dir = Some(d), + Some(u) if d.dot(u) < 1.0 - 1e-12 => return Ok(None), + Some(_) => {} + } + distance += l; + } + Ok(dir.map(|d| (d, distance))) +} + +/// `circular`: whether the path is arcs end to end about one axis; then the centre, the unit axis +/// they turn about counter-clockwise and the total angle (radians). +fn circular(k: &Kernel, given: &[Profile], path: &[Curve]) -> solidcore::Result> { + if given.len() != path.len() || path.is_empty() { + return Ok(None); + } + let (mut first, mut prev, mut angle) = (None::<(Point3, Vec3, f64)>, None::, 0.0); + for (g, c) in given.iter().zip(path) { + let Profile::Arc { center, axis, radius, start, end, .. } = g else { return Ok(None) }; + let (ctr, kv) = (v3(*center), v3(*axis)); + let kv = kv * (1.0 / kv.length()); + let (a, b) = ends(k, c)?; + match first { + None => first = Some((ctr, kv, *radius)), + Some((c0, k0, r0)) => { + if (ctr - c0).length() > 1e-9 + || kv.dot(k0) < 1.0 - 1e-12 + || (radius - r0).abs() > 1e-9 + || prev.is_none_or(|p| (a - p).length() > 1e-9) + { + return Ok(None); + } + } + } + prev = Some(b); + angle += end - start; + } + Ok(first.filter(|_| angle <= TAU * (1.0 + 1e-12)).map(|(c, kv, _)| (c, kv, angle))) +} + +/// A curve's B-spline (`SM_CAST_PTR(SmBSplineCurve, c)`: SMLib's lines and circles are +/// B-splines too); `None` for another kind. +fn bspline(c: &Curve) -> solidcore::Result> { + Ok(match c.kind() { + CurveKind::BSpline(b) => Some(b.clone()), + CurveKind::Line(l) => Some(l.base_with_nurb()?.into_owned()), + CurveKind::Ellipse(e) => Some(e.base().clone()), + _ => None, + }) +} + +/// `joined`: a path of end-to-end curves as one B-spline (`CreateCurveSweep` takes one curve). +fn joined(path: &[Curve]) -> solidcore::Result> { + let mut parts = Vec::with_capacity(path.len()); + for c in path { + let Some(b) = bspline(c)? else { return Ok(Err(Error::Argument)) }; + parts.push(b); + } + if parts.len() == 1 { + return Ok(parts.pop().ok_or(Error::Argument)); + } + Ok(called(BSplineCurve::create_by_joining(&mut parts, None))) +} + +/// `turned`: `v` turned `angle` right-handed about the unit `k` (Rodrigues). +fn turned(v: Vec3, k: Vec3, angle: f64) -> Vec3 { + v * angle.cos() + k.cross(v) * angle.sin() + k * (k.dot(v) * (1.0 - angle.cos())) +} + +/// `put_motion`: a placement as its columns and translation. +fn motion(x: Vec3, y: Vec3, z: Vec3, t: Vec3) -> Affine { + Affine { m: [[x.x, x.y, x.z], [y.x, y.y, y.z], [z.x, z.y, z.z]], t: [t.x, t.y, t.z] } +} + +/// A rigid motion p -> R p + t, R by its columns (`Rigid`). +#[derive(Clone, Copy)] +struct Rigid { + x: Vec3, + y: Vec3, + z: Vec3, + t: Vec3, +} + +impl Rigid { + const IDENTITY: Rigid = + Rigid { x: Vec3::new(1.0, 0.0, 0.0), y: Vec3::new(0.0, 1.0, 0.0), z: Vec3::new(0.0, 0.0, 1.0), t: Vec3::new(0.0, 0.0, 0.0) }; + + /// This motion, then a turn of `angle` right-handed about the unit `k` through `c`. + fn turned_by(&self, c: Vec3, k: Vec3, angle: f64) -> Rigid { + Rigid { x: turned(self.x, k, angle), y: turned(self.y, k, angle), z: turned(self.z, k, angle), t: c + turned(self.t - c, k, angle) } + } + + /// This motion, then a move by `d`. + fn moved_by(&self, d: Vec3) -> Rigid { + Rigid { t: self.t + d, ..*self } + } + + /// As the placement `SmCurve::Transform` takes: the world frame's image. + fn placement(&self) -> Axis2Placement { + Axis2Placement::new(self.t, self.x, self.y) + } + + fn motion(&self) -> Affine { + motion(self.x, self.y, self.z, self.t) + } +} + +/// One segment of a path of lines and arcs (`Segment`): a line from `a` along the unit `dir` for +/// `length`, or an arc turning `angle` (radians) right-handed about the unit `k` through `c`, +/// `length` along. +#[derive(Clone, Copy)] +struct Segment { + arc: bool, + dir: Vec3, + c: Vec3, + k: Vec3, + angle: f64, + length: f64, +} + +impl Segment { + /// The motion `m` carried `s` of the way along (0 <= s <= length). + fn carry(&self, m: &Rigid, s: f64) -> Rigid { + if self.arc { m.turned_by(self.c, self.k, self.angle * s / self.length) } else { m.moved_by(self.dir * s) } + } +} + +/// `segmented`: whether `path` (parsed from `given`) is two or more lines and arcs end to end, +/// each leaving in the direction the one before arrives in, and not closed: then its segments. +fn segmented(k: &Kernel, given: &[Profile], path: &[Curve]) -> solidcore::Result>> { + if given.len() != path.len() || path.len() < 2 { + return Ok(None); + } + let unit = |v: Vec3| v * (1.0 / v.length()); + let mut out = Vec::with_capacity(path.len()); + // The direction the path arrives in at the end of the segment before, and where it ends. + let (mut before, mut first, mut prev) = (None::, None::, None::); + for (g, c) in given.iter().zip(path) { + let (a, b) = ends(k, c)?; + let (seg, leaving, arriving) = match g { + Profile::Line { .. } => { + let length = (b - a).length(); + if !(length > 1e-12) { + return Ok(None); + } + let dir = (b - a) * (1.0 / length); + (Segment { arc: false, dir, c: Vec3::new(0.0, 0.0, 0.0), k: Vec3::new(0.0, 0.0, 0.0), angle: 0.0, length }, dir, dir) + } + Profile::Arc { center, axis, radius, start, end, .. } => { + let (ctr, kv) = (v3(*center), unit(v3(*axis))); + let angle = end - start; + let seg = Segment { arc: true, dir: Vec3::new(0.0, 0.0, 0.0), c: ctr, k: kv, angle, length: radius * angle }; + (seg, unit(kv.cross(a - ctr)), unit(kv.cross(b - ctr))) + } + _ => return Ok(None), + }; + match (first, prev, before) { + (None, ..) => first = Some(a), + (Some(_), Some(p), Some(d)) => { + if (a - p).length() > 1e-9 || leaving.dot(d) < 1.0 - 1e-12 { + return Ok(None); + } + } + _ => return Ok(None), + } + prev = Some(b); + before = Some(arriving); + out.push(seg); + } + match (first, prev) { + (Some(f), Some(p)) if (f - p).length() > 1e-6 => Ok(Some(out)), + _ => Ok(None), + } +} + +/// `carried`: copies of the profile's curves carried by `m`. +fn carried(k: &Kernel, profile: &[Curve], m: &Rigid) -> solidcore::Result> { + let at = m.placement(); + let mut out = Vec::with_capacity(profile.len()); + for c in profile { + let mut copy = c.clone(); + copy.transform(k, &at, None).map_err(|_| SolidError::sm_err("SmCurve::Transform"))?; + out.push(copy); + } + Ok(out) +} + +/// `piecewise`: the profile swept along a path of lines and arcs, turning with it: each segment +/// swept on its own, exactly (a line by SM_API's linear sweep, an arc by its rotational sweep), the +/// profile carried from each to the next, and the walls sewn along the sections they share; with +/// the motions to the first section, the one half-way along and the last. +fn piecewise(k: &mut Kernel, profile: &[Curve], segs: &[Segment], cap: bool) -> solidcore::Result> { + let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + let mut m = Rigid::IDENTITY; + let mut starts = Vec::with_capacity(segs.len()); + let mut total = 0.0; + for g in segs { + starts.push(m); + total += g.length; + let section = carried(k, profile, &m)?; + let refs: Vec> = section.iter().map(Some).collect(); + let piece = if g.arc { + create_rotational_sweep(k, &refs, g.c, g.k, g.angle * 180.0 / PI, false) + } else { + create_linear_sweep(k, &refs, g.dir, g.length, false) + }; + let mut piece = match piece { + Ok(p) => p, + Err(e) => return Ok(Err(status(e))), + }; + let faces = brep_p1::faces(&piece, None)?; + let mut state = ArenaNotifyState::new(&piece); + state.seed(&b); + let mut env = state.env(None); + let key_of = env.key_of; + let this_face = |f: FaceId| key_of(EntityRef::Face(f)); + match copy_faces(k, &mut piece, &faces, &mut b, false, &this_face, &mut env) { + Ok((_, true)) => {} + Ok((_, false)) => return Ok(Err(status(SolidError::sm_err("CopyFaces")))), + Err(e) => return Ok(Err(status(e))), + } + m = g.carry(&m, g.length); + } + let Some(&start) = starts.first() else { return Ok(Err(Error::Argument)) }; + if cap { + // The caps: the profile where the path starts and where it ends. + for at in [start, m] { + let section = carried(k, profile, &at)?; + let (region, tol) = (b.infinite_region(), b.tolerance()); + if let Err(e) = build_faces(&mut b, |b, fenv| create_planar_faces_with_3d_curves(k, b, region, §ion, tol, fenv)) { + return Ok(Err(status(e))); + } + } + } + if let Err(e) = stitch_and_orient(k, &mut b, &StitchAndOrientOptions::default()) { + return Ok(Err(status(e))); + } + b.refresh_cached_topology(); + let mut half = 0.5 * total; + let mut middle = m; + for (i, (g, from)) in segs.iter().zip(&starts).enumerate() { + if half <= g.length || i + 1 == segs.len() { + middle = g.carry(from, half.min(g.length)); + break; + } + half -= g.length; + } + Ok(Ok((b, [start.motion(), middle.motion(), m.motion()]))) +} + +/// `smlib_sweep_placed`: `profile` swept along `path`, with the rigid motions that placed it at +/// the start, half way and the end. Along a straight path the sweep is SM_API's linear sweep, and +/// turning with arcs about one axis its rotational sweep: both build the walls exactly, as the +/// sweep piece by piece does along lines and arcs that turn with the path ([`piecewise`]). +pub(crate) fn sweep_placed(profile: &[Profile], path: &[Profile], frame: SweepFrame, cap: bool) -> Result<(Body, [Affine; 3])> { + let mut placed = [motion(Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, 0.0)); 3]; + let body = making("smlib_sweep", |k| { + let (curves, along) = match (parse(k, profile), parse(k, path)) { + (Ok(c), Ok(p)) => (c, p), + (Err(e), _) | (_, Err(e)) => return Ok(Err(e)), + }; + let refs: Vec> = curves.iter().map(Some).collect(); + if let Some((dir, distance)) = straight(k, &along)? { + let r = match create_linear_sweep(k, &refs, dir, distance, cap) { + Ok(r) => r, + Err(e) => return Ok(Err(status(e))), + }; + for (j, m) in placed.iter_mut().enumerate() { + let t = dir * (distance * 0.5 * j as f64); + m.t = [t.x, t.y, t.z]; + } + return finish(r, cap); + } + if frame == SweepFrame::Follow + && let Some((centre, axis, angle)) = circular(k, path, &along)? + { + let degrees = (angle * 180.0 / PI).min(360.0); + let r = match create_rotational_sweep(k, &refs, centre, axis, degrees, cap) { + Ok(r) => r, + Err(e) => return Ok(Err(status(e))), + }; + let len = axis.length(); + if !(len > 0.0) { + return Ok(Err(Error::Argument)); + } + let kv = axis * (1.0 / len); + let turn = degrees * PI / 180.0; + for (j, m) in placed.iter_mut().enumerate() { + let a = turn * 0.5 * j as f64; + let t = centre - turned(centre, kv, a); + let [x, y, z] = [Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0), Vec3::new(0.0, 0.0, 1.0)].map(|e| turned(e, kv, a)); + *m = motion(x, y, z, t); + } + return finish(r, cap); + } + // Along lines and arcs, turning with the path, the sweep is exact piece by piece. + if frame == SweepFrame::Follow + && let Some(segs) = segmented(k, path, &along)? + { + return Ok(match piecewise(k, &curves, &segs, cap)? { + Ok((b, motions)) => { + placed = motions; + finish(b, cap)? + } + Err(e) => Err(e), + }); + } + let path_curve = match joined(&along)? { + Ok(b) => Curve::from(b), + Err(e) => return Ok(Err(e)), + }; + // Round a closed path the profile comes back onto itself: the walls close on each other + // there, where caps would lie on top of each other inside the solid. + let (Some(head), Some(tail)) = (along.first(), along.last()) else { return Ok(Err(Error::Argument)) }; + let closed = (ends(k, head)?.0 - ends(k, tail)?.1).length() <= 1e-6; + // `SmApiCreateCurveSweep` with `bProfileAtPathStart` (fork 2dd71a0), so the profile is + // swept where it lies, and the placements it reports (fork 5d3939e). + let options = SweepOptions { + cap_ends: cap && !closed, + translational_sweep: frame == SweepFrame::Translate, + profile_at_path_start: true, + ..SweepOptions::default() + }; + let r = match create_curve_sweep_with_placements(k, &refs, Some(&path_curve), None, None, Some(&options)) { + Ok(r) => r, + Err(e) => return Ok(Err(status(e))), + }; + for (m, p) in placed.iter_mut().zip(&r.placements) { + *m = motion(p.x_axis(), p.y_axis(), p.z_axis(), p.origin()); + } + let mut b = r.sweep.brep; + if closed { + let tol = b.tolerance(); + match stitch_faces(k, &mut b, tol, None, false) { + Ok(s) if s.lamina_edges != 0 => return Ok(Err(Error::NotSolid)), + Ok(_) => b.refresh_cached_topology(), + Err(e) => return Ok(Err(status(e))), + } + } + finish(b, cap) + })?; + Ok((body, placed)) +} + +/// `smlib_loft`: a skin through `sections` (`SmApiCreateSkinPrimitive`), `degree` across them. +pub(crate) fn loft(sections: &[Vec], degree: u32, cap: bool) -> Result { + if sections.len() < 2 || sections.len() > MAX_CURVES || !(1..=3).contains(°ree) { + return Err(Error::Argument); + } + let all: Vec = sections.iter().flatten().cloned().collect(); + making("smlib_loft", |k| { + let curves = match parse(k, &all) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + if sections.iter().any(|s| s.is_empty() || s.len() > MAX_CURVES) { + return Ok(Err(Error::Argument)); + } + let counts: Vec = sections.iter().map(Vec::len).collect(); + let refs: Vec> = curves.iter().map(Some).collect(); + match create_skin_primitive(k, &counts, &refs, degree as usize, cap) { + Ok(r) => finish(r.brep, cap), + Err(e) => Ok(Err(status(e))), + } + }) +} + +/// Each face's identity, for the face builders: their arena identity. +pub(super) fn build_faces(b: &mut Brep, f: impl FnOnce(&mut Brep, &mut FaceBuildEnv<'_, '_>) -> T) -> T { + let mut state = ArenaNotifyState::new(b); + let mut env = state.env(None); + let key_of = env.key_of; + let this_face = |f: FaceId| key_of(EntityRef::Face(f)); + let mut marks = Marks::default(); + let mut fenv = FaceBuildEnv { env: &mut env, marks: &mut marks, this_face: &this_face }; + f(b, &mut fenv) +} + +/// `smlib_loft_to_point`: a pyramid from the planar `section` (one closed loop of lines, in +/// order) to `apex`: the base and a triangle on each line, each `CreatePlanarFaceWith3DCurves` of +/// new lines, then `StitchAndOrient`. +pub(crate) fn loft_to_point(section: &[Profile], apex: [f64; 3]) -> Result { + if !coord_ok(&apex) { + return Err(Error::Argument); + } + making("smlib_loft_to_point", |k| { + let curves = match parse(k, section) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + if curves.len() < 3 || !one_loop(k, &curves, 1e-6)? { + return Ok(Err(Error::Argument)); + } + // The base's corners, each line's start. + let mut corners = Vec::with_capacity(curves.len()); + for c in &curves { + if !matches!(c.kind(), CurveKind::Line(_)) { + return Ok(Err(Error::Argument)); + } + corners.push(ends(k, c)?.0); + } + let Ok(normal) = compute_crvs_normal(k, &curves) else { return Ok(Err(Error::Argument)) }; + let top = v3(apex); + let Some(&c0) = corners.first() else { return Ok(Err(Error::Argument)) }; + let size = corners.iter().map(|q| (*q - c0).length()).fold(0.0, f64::max); + if !((top - c0).dot(normal).abs() > 1e-9 * size.max(1.0)) { + return Ok(Err(Error::Argument)); + } + let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + let region = b.infinite_region(); + let tol = 1e-6; + let made = build_faces(&mut b, |b, fenv| -> Out<()> { + let mut face = |b: &mut Brep, fenv: &mut FaceBuildEnv<'_, '_>, pts: &[Point3]| -> Out<()> { + let mut edges = Vec::with_capacity(pts.len()); + for (i, &p) in pts.iter().enumerate() { + let q = pts.get((i + 1) % pts.len()).copied().unwrap_or(p); + edges.push(Curve::from(called(Line::create_line_segment(3, p, q, None))?)); + } + called(create_planar_face_with_3d_curves(k, b, region, &edges, tol, fenv)).map(|_| ()) + }; + face(b, fenv, &corners)?; + for (i, &p) in corners.iter().enumerate() { + let q = corners.get((i + 1) % corners.len()).copied().unwrap_or(p); + face(b, fenv, &[p, q, top])?; + } + Ok(()) + }); + if let Err(e) = made { + return Ok(Err(e)); + } + if let Err(e) = stitch_and_orient(k, &mut b, &StitchAndOrientOptions::default()) { + return Ok(Err(status(e))); + } + b.refresh_cached_topology(); + finish(b, true) + }) +} + +/// `maps_ok`: each station map finite, of bounded size and far from singular, its translation +/// within `MAX_COORD`. +fn maps_ok(stations: &[Affine]) -> bool { + stations.iter().all(|s| { + let m = &s.m; + if !m.iter().flatten().chain(&s.t).all(|x| x.is_finite()) || !coord_ok(&s.t) { + return false; + } + let len = |c: &[f64; 3]| (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt(); + let (c0, c1, c2) = (len(&m[0]), len(&m[1]), len(&m[2])); + if !(c0 < 1e6 && c1 < 1e6 && c2 < 1e6) { + return false; + } + let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) - m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2]) + + m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]); + det.abs() > 1e-9 * c0 * c1 * c2 + }) +} + +/// `placed`: a copy of `c` as a plain B-spline with every control point carried by `s`, the +/// weights unchanged (an affine map moves a NURBS curve exactly by its control points). +fn placed(c: &BSplineCurve, s: &Affine) -> Out { + let can = called(c.canonical())?; + if can.dim != 3 { + return Err(Error::Status(1001)); + } + let m = &s.m; + let points: Vec = can + .control_points + .iter() + .map(|p| { + Vec3::new( + m[0][0] * p.x + m[1][0] * p.y + m[2][0] * p.z + s.t[0], + m[0][1] * p.x + m[1][1] * p.y + m[2][1] * p.z + s.t[1], + m[0][2] * p.x + m[1][2] * p.y + m[2][2] * p.z + s.t[2], + ) + }) + .collect(); + let data = CanonicalData { + dim: 3, + degree: can.degree, + control_points: &points, + form: BSplineCurveForm::Unspecified, + knot_multiplicities: &can.knot_multiplicities, + knots: &can.knots, + knot_type: KnotType::Unspecified, + weights: (!can.weights.is_empty()).then_some(can.weights.as_slice()), + trim: None, + }; + called(BSplineCurve::create_canonical(&data)).map(Curve::from) +} + +/// `smlib_sweep_stations`: the profile placed by each station, its sections skinned through +/// (`SmApiCreateSkinPrimitive`, cubic across them or as high as their number allows), the ends +/// capped, or joined by `SmApiStitchIntoSolid`. +pub(crate) fn sweep_stations(profile: &[Profile], stations: &[Affine], end: Ends) -> Result { + if !(2..=MAX_STATIONS).contains(&stations.len()) { + return Err(Error::Argument); + } + making("smlib_sweep_stations", |k| { + if !maps_ok(stations) { + return Ok(Err(Error::Argument)); + } + let curves = match parse(k, profile) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + if curves.len() as f64 * stations.len() as f64 > MAX_STATION_CURVES as f64 { + return Ok(Err(Error::Argument)); + } + // Every section is the profile carried by its station's map: the sections share the + // profile's degrees and knots, which the skin interpolates control point by control point. + let mut sections = Vec::with_capacity(curves.len() * stations.len()); + let mut counts = Vec::with_capacity(stations.len()); + for s in stations { + for c in &curves { + let Some(b) = bspline(c)? else { return Ok(Err(Error::Argument)) }; + match placed(&b, s) { + Ok(p) => sections.push(p), + Err(e) => return Ok(Err(e)), + } + } + counts.push(curves.len()); + } + let degree = (stations.len() - 1).min(3); + let refs: Vec> = sections.iter().map(Some).collect(); + let mut r = match create_skin_primitive(k, &counts, &refs, degree, end == Ends::Capped) { + Ok(r) => r.brep, + Err(e) => return Ok(Err(status(e))), + }; + if end == Ends::Joined { + // The last section lies on the first: the walls' ends are glued to their starts. + match api_stitch_into_solid(k, Some(&mut r)) { + Ok(s) if s.produces_a_solid => {} + Ok(_) => return Ok(Err(Error::NotSolid)), + Err(e) => return Ok(Err(status(e))), + } + } + finish(r, end != Ends::Open) + }) +} + +/// The entry checks of `smlib_screw_sweep` and `smlib_screw_sweep_radial`. +fn screw_ok(origin: [f64; 3], pitch: f64, turns: f64, tolerance: f64) -> bool { + coord_ok(&origin) + && pitch.is_finite() + && pitch.abs() > 1e-6 + && pitch.abs() < MAX_COORD + && turns.is_finite() + && (1e-3..=SCREW_MAX_TURNS).contains(&turns) + && tolerance.is_finite() + && (1e-6..1e3).contains(&tolerance) +} + +/// `SMLIB_SCREW_MAX_STATIONS`: most spans of one screw sweep. +const SCREW_MAX_STATIONS: f64 = 12000.0; + +/// `Screw`: the screw motion about the axis through `o` along the unit `k`, rising `pitch` per +/// turn, the section carried by angle a (radians, right-handed about k) and first moved off the +/// axis by offset(a) along `e` (the radial sweep: the unit direction from the axis into the +/// profile's half-plane), offset(a) piecewise linear through (`angles`, `offsets`) and held +/// beyond them. No offsets: none. +struct Screw { + o: Point3, + k: Vec3, + e: Vec3, + pitch: f64, + angles: Vec, + offsets: Vec, +} + +impl Screw { + fn turn(&self, v: Vec3, a: f64) -> Vec3 { + turned(v, self.k, a) + } + + fn lift(&self, a: f64) -> f64 { + self.pitch * a / TAU + } + + fn offset(&self, a: f64) -> f64 { + let (Some(&a0), Some(&o0), Some(&on)) = (self.angles.first(), self.offsets.first(), self.offsets.last()) else { return 0.0 }; + if !(a > a0) { + return o0; + } + for i in 1..self.angles.len() { + let (Some(&ai), Some(&ap), Some(&oi), Some(&op)) = + (self.angles.get(i), self.angles.get(i - 1), self.offsets.get(i), self.offsets.get(i - 1)) + else { + break; + }; + if a <= ai { + return op + (oi - op) * (a - ap) / (ai - ap); + } + } + on + } + + /// The move off the axis at angle a, before the turn. + fn shift(&self, a: f64) -> Vec3 { + if self.offsets.is_empty() { Vec3::new(0.0, 0.0, 0.0) } else { self.e * self.offset(a) } + } + + /// Where p goes at angle a. + fn at(&self, p: Point3, a: f64) -> Point3 { + self.o + self.turn(p - self.o + self.shift(a), a) + self.k * self.lift(a) + } + + /// How far p lies from the axis. + fn reach(&self, p: Point3) -> f64 { + let w = p - self.o; + (w - self.k * w.dot(self.k)).length() + } +} + +/// `Stations`: n spans of h radians each, from t[0] to t[n]. Each span carries the profile round +/// as an exact rational quadratic arc carries a point (its middle control point at the span's +/// middle angle, out by 1 / cos(h / 2), weighted cos(h / 2)), and lifts it by the same rational +/// quadratic of the lifts at the span's ends and middle. +struct Stations { + t: Vec, + /// h / 2. + half: f64, +} + +impl Stations { + fn new(from: f64, to: f64, n: usize) -> Stations { + let t = (0..=n).map(|i| if i == n { to } else { from + (to - from) * i as f64 / n as f64 }).collect(); + Stations { t, half: 0.5 * (to - from) / n as f64 } + } + + fn spans(&self) -> usize { + self.t.len() - 1 + } + + fn first(&self) -> f64 { + self.t.first().copied().unwrap_or(0.0) + } + + fn last(&self) -> f64 { + self.t.last().copied().unwrap_or(0.0) + } + + /// The knots across the stations: double inside (the arcs meet tangent), triple at the ends. + fn knots(&self) -> (Vec, Vec) { + let n = self.t.len(); + (self.t.clone(), (0..n).map(|i| if i == 0 || i + 1 == n { 3 } else { 2 }).collect()) + } + + /// The weight of control point j (of 2 spans + 1) across the stations. + fn weight(&self, j: usize) -> f64 { + if j.is_multiple_of(2) { 1.0 } else { self.half.cos() } + } +} + +/// `lift_error`: how far, in radians of turn, the rational quadratic lift of a span of +/// half-angle `half` leads or lags the true lift, at most (about 0.008 h³). +fn lift_error(half: f64) -> f64 { + let mut worst: f64 = 0.0; + for i in 0..=2048 { + let s = i as f64 / 2048.0; + let lift = half * s / (1.0 - (1.0 - s * s) * (1.0 - half.cos()) / 2.0); + worst = worst.max((lift - 2.0 * ((half / 2.0).tan() * s).atan()).abs()); + } + worst +} + +/// `offset_error`: how far, per unit change of offset across a span of half-angle `half`, the +/// rational quadratic offset strays from the true offset turned with the profile, at most +/// (about h / 8). +fn offset_error(half: f64) -> f64 { + let (c, h) = (half.cos(), 2.0 * half); + let mut worst: f64 = 0.0; + for i in 0..=4096 { + let s = -1.0 + i as f64 / 2048.0; + let t = 0.5 * (s + 1.0); + let (b0, b1, b2) = ((1.0 - t) * (1.0 - t), 2.0 * t * (1.0 - t), t * t); + let den = b0 + c * b1 + b2; + let (ax, ay) = (0.5 * (b2 - b0) * c / den, 0.5 * (b2 + b0) * half.sin() / den); + let phi = 2.0 * ((half / 2.0).tan() * s).atan(); + worst = worst.max((ax - phi / h * phi.cos()).hypot(ay - phi / h * phi.sin())); + } + worst +} + +/// `path`: the control points of p's path under the screw motion over `st` (2 spans + 1). +fn screw_path(m: &Screw, p: Point3, st: &Stations) -> Vec { + let w = p - m.o; + let axial = w.dot(m.k); + let radial = w - m.k * axial; + let mut d = Vec::with_capacity(2 * st.spans() + 1); + for (i, &ti) in st.t.iter().enumerate() { + d.push(m.at(p, ti)); + if let Some(&tn) = st.t.get(i + 1) { + let mid = 0.5 * (ti + tn); + d.push(m.o + m.turn(radial + m.shift(mid), mid) * (1.0 / st.half.cos()) + m.k * (axial + m.lift(mid))); + } + } + d +} + +/// `path_curve`: the path of p over `st` as a curve, exactly as the walls either side have it. +fn path_curve(m: &Screw, p: Point3, st: &Stations) -> Out { + let points = screw_path(m, p, st); + let weights: Vec = (0..points.len()).map(|j| st.weight(j)).collect(); + let (knots, mult) = st.knots(); + let data = CanonicalData { + dim: 3, + degree: 2, + control_points: &points, + form: BSplineCurveForm::Unspecified, + knot_multiplicities: &mult, + knots: &knots, + knot_type: KnotType::Unspecified, + weights: Some(&weights), + trim: None, + }; + called(BSplineCurve::create_canonical(&data)).map(Curve::from) +} + +/// `axial_radius`: the radius of the cylinder a profile line parallel to the axis sweeps (within +/// a thousandth of `tolerance`, at least 1e-6 off it); 0 for any other curve. +fn axial_radius(k: &Kernel, m: &Screw, c: &Curve, tolerance: f64) -> solidcore::Result { + if !matches!(c.kind(), CurveKind::Line(_)) { + return Ok(0.0); + } + let (a, b) = ends(k, c)?; + // How far a point of the line strays from the axis lies between the ends' and the nearest + // point's (the distance is convex along the line). + let (w, d) = (a - m.o, b - a); + let (wp, dp) = (w - m.k * w.dot(m.k), d - m.k * d.dot(m.k)); + let t = if dp.dot(dp) > 0.0 { (-wp.dot(dp) / dp.dot(dp)).clamp(0.0, 1.0) } else { 0.0 }; + let (ra, rb, nearest) = (m.reach(a), m.reach(b), (wp + dp * t).length()); + let (far, near) = (ra.max(rb), nearest.min(ra.min(rb))); + Ok(if near >= 1e-6 && far - near <= 1e-3 * tolerance { 0.5 * (ra + rb) } else { 0.0 }) +} + +/// `cylinder_piece`: the piece of the cylinder of `radius` the profile line from `a` to `b` +/// sweeps over `st`: an exact cylinder about the axis, bounded by the line placed at both ends of +/// the piece and its ends' paths (`CreateFacesWithCurves`). The cylinder's own turn starts a +/// quarter turn before the piece and its height runs past it. +fn cylinder_piece(k: &mut Kernel, b: &mut Brep, m: &Screw, a: Point3, e: Point3, radius: f64, st: &Stations) -> solidcore::Result> { + let (a0, a1) = (st.first(), st.last()); + let w = a - m.o; + let r = w - m.k * w.dot(m.k); + let r = r * (1.0 / r.length()); + let x = m.turn(r, a0 - 0.25 * TAU); + let y = m.k.cross(x); + let (za, zb) = ((a - m.o).dot(m.k), (e - m.o).dot(m.k)); + let z0 = za.min(zb) + m.lift(a0).min(m.lift(a1)); + let z1 = za.max(zb) + m.lift(a0).max(m.lift(a1)); + let margin = 0.25 * (z1 - z0) + 1e-3; + let base = m.o + m.k * (z0 - margin); + let cylinder = Cone::new_cylinder_bounded(base, x, y, radius, 0.0, 360.0, z1 - z0 + 2.0 * margin, false, false, false); + let edge = |at: f64| called(Line::create_line_segment(3, m.at(a, at), m.at(e, at), None)).map(Curve::from); + let lp = || -> Out> { Ok(vec![edge(a0)?, path_curve(m, e, st)?, edge(a1)?, path_curve(m, a, st)?]) }; + let curves = match lp() { + Ok(c) => c, + Err(err) => return Ok(Err(err)), + }; + let orient = [OrientType::Same, OrientType::Same, OrientType::Opposite, OrientType::Opposite]; + let region = b.infinite_region(); + let made = build_faces(b, |b, fenv| { + create_faces_with_curves(k, b, region, &[4], Some(curves.into_iter().map(Some).collect()), &orient, Some(Surface::from(cylinder)), fenv) + }); + Ok(match made { + Ok(f) if f.faces.len() == 1 => Ok(()), + Ok(_) => Err(Error::Status(1001)), + Err(err) => Err(status(err)), + }) +} + +/// `screw_wall`: the wall one profile curve sweeps over `st`: across the curve its NURBS, across +/// the turns each control point's path, the weights the curve's times the arcs'. +fn screw_wall(m: &Screw, c: &CurveCanonical, st: &Stations) -> Out { + let across = 2 * st.spans() + 1; + let mut net = Vec::with_capacity(c.control_points.len() * across); + let mut weights = Vec::with_capacity(net.capacity()); + for (l, &p) in c.control_points.iter().enumerate() { + let w = c.weights.get(l).copied().unwrap_or(1.0); + for (j, q) in screw_path(m, p, st).into_iter().enumerate() { + net.push(q); + weights.push(w * st.weight(j)); + } + } + let (vknots, vmult) = st.knots(); + let data = CanonicalSurfaceData { + u_degree: c.degree, + v_degree: 2, + control_points: &net, + form: BSplineSurfaceForm::Unspecified, + u_multiplicities: &c.knot_multiplicities, + v_multiplicities: &vmult, + u_knots: &c.knots, + v_knots: &vknots, + knot_type: KnotType::Unspecified, + weights: Some(&weights), + uv_domain: None, + }; + called(BSplineSurface::create_canonical(&data)).map(Surface::from) +} + +/// The profile curve `c` carried by the screw motion's placement at angle `a` (`Copy`, then +/// `Transform`): a rigid motion, so a line stays a line and an arc an arc. +fn screw_placed(m: &Screw, c: &Profile, a: f64) -> Profile { + let p = |q: [f64; 3]| { + let r = m.at(v3(q), a); + [r.x, r.y, r.z] + }; + let d = |q: [f64; 3]| { + let r = m.turn(v3(q), a); + [r.x, r.y, r.z] + }; + match c { + Profile::Line { start, end } => Profile::Line { start: p(*start), end: p(*end) }, + Profile::Arc { center, axis, reference, radius, start, end } => { + Profile::Arc { center: p(*center), axis: d(*axis), reference: d(*reference), radius: *radius, start: *start, end: *end } + } + Profile::Nurbs { degree, points, knots } => Profile::Nurbs { + degree: *degree, + points: points + .iter() + .map(|q| { + let r = p([q[0], q[1], q[2]]); + [r[0], r[1], r[2], q[3]] + }) + .collect(), + knots: knots.clone(), + }, + } +} + +/// `screw_sweep` (smlib_sweeps.cpp): the profile's walls built from the screw motion itself, span +/// by span (SM_API's curve sweep carries the profile on its own frame, which along a helix turns +/// it away from the screw motion), lines parallel to the axis as exact cylinder pieces, the caps +/// where the motion starts and ends, all stitched and oriented. +fn screw(k: &mut Kernel, profile: &[Profile], mut m: Screw, turns: f64, tolerance: f64) -> solidcore::Result> { + let curves = match parse(k, profile) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + let mut walls = Vec::with_capacity(curves.len()); + let (mut reach, mut farthest) = (0.0, Vec3::new(0.0, 0.0, 0.0)); + for c in &curves { + let Some(b) = bspline(c)? else { return Ok(Err(Error::Status(1001))) }; + let can = b.canonical()?; + if can.dim != 3 { + return Ok(Err(Error::Status(1001))); + } + for &p in &can.control_points { + let v = p - m.o; + let v = v - m.k * v.dot(m.k); + if v.length() > reach { + reach = v.length(); + farthest = v; + } + } + walls.push(can); + } + if !(reach > 1e-6) { + return Ok(Err(Error::Argument)); + } + // An offset moves the profile within its half-plane: every control point must lie in the one + // the farthest of them spans with the axis, and stay off the axis moved. + if !m.offsets.is_empty() { + let mut nearest = reach; + m.e = farthest * (1.0 / reach); + for p in walls.iter().flat_map(|w| &w.control_points) { + let v = *p - m.o; + let v = v - m.k * v.dot(m.k); + let along = v.dot(m.e); + if !((v - m.e * along).length() <= 1e-9 * (1.0 + reach) && along >= -1e-9 * (1.0 + reach)) { + return Ok(Err(Error::Argument)); + } + nearest = nearest.min(along); + } + if m.offsets.iter().any(|d| !(nearest + d > 1e-6)) { + return Ok(Err(Error::Argument)); + } + } + // Walls of lines parallel to the axis are exact cylinders, which cannot turn on themselves: + // then every wall comes in pieces of half a turn (the last up to an eighth longer). + let mut radius = Vec::with_capacity(curves.len()); + for c in &curves { + radius.push(axial_radius(k, &m, c, tolerance)?); + } + let cylinders = radius.iter().any(|&r| r > 0.0); + // The offset changes slope only at its own angles: they bound pieces too, and a half turn + // within a sixteenth of a turn of one is left out. + let total = TAU * turns; + let mut breaks = vec![0.0]; + breaks.extend(m.angles.iter().copied().filter(|&a| a > 0.0 && a < total)); + let mut half = 0.5 * TAU; + while cylinders && half < total - TAU / 16.0 { + if !m.angles.iter().any(|a| (a - half).abs() < TAU / 16.0) { + breaks.push(half); + } + half += 0.5 * TAU; + } + breaks.push(total); + breaks.sort_by(f64::total_cmp); + let rise = m.pitch.abs() / TAU; + let mut h = (TAU / 8.0).min((tolerance / (0.008 * rise)).cbrt()); + // (bounded: a span this narrow would need more than SMLIB_SCREW_MAX_STATIONS) + let mut guard = 0; + while rise * lift_error(0.5 * h) > tolerance && guard < 10_000 { + h *= 0.99; + guard += 1; + } + let mut pieces = Vec::with_capacity(breaks.len()); + let mut spans = 0.0; + for pair in breaks.windows(2) { + let &[from, to] = pair else { continue }; + // Where the offset changes, its spans are narrower still. + let rate = (m.offset(to) - m.offset(from)).abs() / (to - from); + let mut piece = h; + while rise * lift_error(0.5 * piece) + rate * piece * offset_error(0.5 * piece) > tolerance && piece > 1e-4 { + piece *= 0.99; + } + let n = ((to - from) / piece).ceil(); + spans += n; + if !(spans <= SCREW_MAX_STATIONS) || !(n >= 1.0) { + return Ok(Err(Error::Argument)); + } + pieces.push(Stations::new(from, to, n as usize)); + } + let mut b = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + for st in &pieces { + for (i, (c, wall)) in curves.iter().zip(&walls).enumerate() { + let r = radius.get(i).copied().unwrap_or(0.0); + if r > 0.0 && m.offset(st.first()) == m.offset(st.last()) { + let (a, e) = ends(k, c)?; + if let Err(err) = cylinder_piece(k, &mut b, &m, a, e, r + m.offset(st.first()), st)? { + return Ok(Err(err)); + } + continue; + } + let surface = match screw_wall(&m, wall, st) { + Ok(s) => s, + Err(err) => return Ok(Err(err)), + }; + let domain = surface.natural_uv_domain_with_nurb(k)?; + if let Err(err) = build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(surface), &domain, fenv)) { + return Ok(Err(status(err))); + } + } + } + // The caps: the profile where the motion starts and where it ends, placed exactly. + for a in [0.0, total] { + let placed: Vec = profile.iter().map(|c| screw_placed(&m, c, a)).collect(); + let cap = match parse(k, &placed) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + let (region, tol) = (b.infinite_region(), b.tolerance()); + if let Err(err) = build_faces(&mut b, |b, fenv| create_planar_faces_with_3d_curves(k, b, region, &cap, tol, fenv)) { + return Ok(Err(status(err))); + } + } + if let Err(e) = stitch_and_orient(k, &mut b, &StitchAndOrientOptions::default()) { + return Ok(Err(status(e))); + } + b.refresh_cached_topology(); + finish(b, true) +} + +/// `smlib_screw_sweep` and `smlib_screw_sweep_radial` (`law`: the radial offset law, as +/// (turn, offset) pairs). +pub(crate) fn screw_sweep( + profile: &[Profile], + origin: [f64; 3], + axis: [f64; 3], + pitch: f64, + turns: f64, + law: Option<&[(f64, f64)]>, + tolerance: f64, +) -> Result { + if !screw_ok(origin, pitch, turns, tolerance) { + return Err(Error::Argument); + } + if let Some(law) = law { + if law.is_empty() || law.len() > SCREW_MAX_LAW || !law.iter().all(|(t, o)| t.is_finite() && o.is_finite()) { + return Err(Error::Argument); + } + for (i, (t, o)) in law.iter().enumerate() { + let ascending = i == 0 || law.get(i - 1).is_some_and(|(p, _)| t > p); + if !(*t >= 0.0 && *t <= turns) || !ascending || !(o.abs() < MAX_COORD) { + return Err(Error::Argument); + } + } + } + let call = if law.is_some() { "smlib_screw_sweep_radial" } else { "smlib_screw_sweep" }; + making(call, |k| { + let Some(axis) = unit(axis) else { return Ok(Err(Error::Argument)) }; + let law = law.unwrap_or_default(); + let m = Screw { + o: v3(origin), + k: axis, + e: Vec3::new(0.0, 0.0, 0.0), + pitch, + angles: law.iter().map(|(t, _)| TAU * t).collect(), + offsets: law.iter().map(|(_, o)| *o).collect(), + }; + screw(k, profile, m, turns, tolerance) + }) +} + +/// `put_curve`: `c` as the NURBS it is, with its interval; [`Error::Unsupported`] for a curve that +/// is no B-spline or whose knots are not clamped. +fn put_curve(c: &Curve) -> solidcore::Result> { + let Some(b) = bspline(c)? else { return Ok(Err(Error::Unsupported)) }; + let can = b.canonical()?; + let degree = can.degree; + let count = can.control_points.len(); + let mut knots = Vec::with_capacity(count + degree + 1); + for (&u, &m) in can.knots.iter().zip(&can.knot_multiplicities) { + knots.extend(std::iter::repeat_n(u, m)); + } + let clamped = can.knot_multiplicities.first().is_some_and(|&m| m > degree) && can.knot_multiplicities.last().is_some_and(|&m| m > degree); + if !(1..=ffi::sweeps::CURVE_MAX_DEGREE).contains(°ree) + || count < degree + 1 + || count > ffi::sweeps::CURVE_MAX_POINTS + || knots.len() != count + degree + 1 + || !clamped + { + return Ok(Err(Error::Unsupported)); + } + let points = can.control_points.iter().enumerate().map(|(i, p)| [p.x, p.y, p.z, can.weights.get(i).copied().unwrap_or(1.0)]).collect(); + let ivl = c.natural_interval(); + Ok(Ok((Profile::Nurbs { degree, points, knots }, [ivl.min(), ivl.max()]))) +} + +/// The pick tolerance and the curve tolerance of `smlib_project_curves` and `smlib_face_section`. +fn tolerances_ok(pick: f64, tolerance: f64) -> bool { + pick.is_finite() && pick > 0.0 && pick < 1e6 && tolerance.is_finite() && (1e-9..1e3).contains(&tolerance) +} + +/// The face of `b` nearest `point` (`smlib_pick::pick_faces` of one point). +fn picked_face(k: &mut Kernel, b: &mut Brep, point: [f64; 3], tol: f64) -> solidcore::Result> { + Ok(match pick_faces(k, b, &[point], tol)? { + Ok(f) => f.first().copied().ok_or(Error::NoMatch { point: 0 }), + Err(m) => Err(face_miss(m)), + }) +} + +/// `smlib_project_curves`: `curves` projected along `direction` onto the face nearest +/// `face_point` (`SmBrep::CreateParallelProjectionCurves` on that face), as NURBS with their +/// intervals; none is [`Error::Empty`]. +pub(crate) fn project_curves( + body: &Body, + face_point: [f64; 3], + pick_tolerance: f64, + curves: &[Profile], + direction: [f64; 3], + tolerance: f64, +) -> Result> { + if !face_point.iter().chain(&direction).all(|x| x.is_finite()) || !tolerances_ok(pick_tolerance, tolerance) { + return Err(Error::Argument); + } + let len = v3(direction).length(); + if !(len > 1e-12) { + return Err(Error::Argument); + } + with_body("smlib_project_curves", body, |k, b| { + let given = match parse(k, curves) { + Ok(c) => c, + Err(e) => return Ok(Err(e)), + }; + let face = match picked_face(k, b, face_point, pick_tolerance)? { + Ok(f) => f, + Err(e) => return Ok(Err(e)), + }; + let dir = v3(direction) * (1.0 / len); + let faces = [face]; + let opts = ParallelProjectionOptions { + approx_tol_3d: Some(tolerance), + subset_faces: Some(&faces), + want_3d: true, + ..ParallelProjectionOptions::default() + }; + let mut out = Vec::new(); + for c in &given { + // curve->GetRootCurve(): a line, an arc and a B-spline are their own root, and + // go in as themselves + if !matches!(c.kind(), CurveKind::BSpline(_) | CurveKind::Line(_) | CurveKind::Ellipse(_)) { + return Ok(Err(Error::Argument)); + } + let made = match create_parallel_projection_curves(k, b, c, dir, &opts) { + Ok(m) => m.curves_3d.unwrap_or_default(), + Err(e) => return Ok(Err(status(e))), + }; + for m in &made { + match put_curve(m)? { + Ok(x) => out.push(x), + Err(e) => return Ok(Err(e)), + } + } + } + Ok(if out.is_empty() { Err(Error::Empty) } else { Ok(out) }) + })? +} + +/// `smlib_face_section`: the section of the face nearest `face_point` by the plane through +/// `plane_point` square to `plane_normal` (`SmBrep::CreatePlanarSectionCurves` over the whole +/// B-rep, keeping the curves of the picked face), as NURBS with their intervals; none is +/// [`Error::Empty`]. +pub(crate) fn face_section( + body: &Body, + face_point: [f64; 3], + pick_tolerance: f64, + plane_point: [f64; 3], + plane_normal: [f64; 3], + tolerance: f64, +) -> Result> { + if !face_point.iter().chain(&plane_point).chain(&plane_normal).all(|x| x.is_finite()) || !tolerances_ok(pick_tolerance, tolerance) { + return Err(Error::Argument); + } + let Some(normal) = unit(plane_normal) else { return Err(Error::Argument) }; + with_body("smlib_face_section", body, |k, b| { + let face = match picked_face(k, b, face_point, pick_tolerance)? { + Ok(f) => f, + Err(e) => return Ok(Err(e)), + }; + // UV curves are asked for, so the classify is forced: an analytic face's intersector + // makes none, and without it the trim drops the whole section (SmBrep.h documents the + // flag for this). The capi leaves it FALSE, so the C++ kernel answers Empty on planes, + // cylinders, cones and spheres (solidcore#1769). + let opts = PlanarSectionOptions { + approx_tol_3d: Some(tolerance), + outputs: SectionOutputs { curves_3d: true, uv_curves: true, faces: true }, + force_uv_classify: true, + ..PlanarSectionOptions::default() + }; + let made = match create_planar_section_curves(k, b, v3(plane_point), normal, &opts) { + Ok(m) => m, + Err(e) => return Ok(Err(status(e))), + }; + let curves = made.curves_3d.unwrap_or_default(); + let faces = made.faces.unwrap_or_default(); + // (each curve's face: without one apiece the picked face's curves are not known) + if faces.len() != curves.len() { + return Ok(Err(Error::Unsupported)); + } + let mut out = Vec::new(); + for (c, &f) in curves.iter().zip(&faces) { + if f == face { + match put_curve(c)? { + Ok(x) => out.push(x), + Err(e) => return Ok(Err(e)), + } + } + } + Ok(if out.is_empty() { Err(Error::Empty) } else { Ok(out) }) + })? +} diff --git a/crates/smlib/src/core/sweep_tests.rs b/crates/smlib/src/core/sweep_tests.rs new file mode 100644 index 0000000000..257ee4a182 --- /dev/null +++ b/crates/smlib/src/core/sweep_tests.rs @@ -0,0 +1,768 @@ +//! Extrusions, revolutions, sweeps, lofts and planar faces on solidcore, against closed forms: +//! every result exports to a record that validates. With SMLib built, the same case on the C++ +//! kernel gives the same volume (or, for a sheet, area), counts and faces by surface kind, or the +//! same error (parity only: the expected values are the analytic ones). +//! +//! The profiles are built straight into solidcore curves from the curve lists. + +use std::collections::BTreeMap; +use std::f64::consts::{FRAC_PI_2, PI, TAU}; +use std::sync::mpsc; +use std::time::Duration; + +use super::sweep::{extrude, face_section, loft, loft_to_point, planar_faces, project_curves, revolve, screw_sweep, sweep_placed, sweep_stations}; +use super::{Body, area, bounds, counts, make_box, make_cylinder, to_arrays, volume}; +use crate::Error; +use crate::export::native; +use crate::sweeps::{Affine, CornerRefusal, Curve, DraftCorner, Ends, SweepFrame}; + +/// A case longer than this is a hang. +const TIMEOUT: Duration = Duration::from_secs(300); + +const Z: [f64; 3] = [0.0, 0.0, 1.0]; + +fn line(a: [f64; 3], b: [f64; 3]) -> Curve { + Curve::Line { start: a, end: b } +} + +/// The closed polygon through `pts`. +fn polygon(pts: &[[f64; 3]]) -> Vec { + (0..pts.len()).map(|i| line(pts[i], pts[(i + 1) % pts.len()])).collect() +} + +/// The square of side `s` from `(x, y)` in the plane z = `z`. +fn square(x: f64, y: f64, s: f64, z: f64) -> Vec { + polygon(&[[x, y, z], [x + s, y, z], [x + s, y + s, z], [x, y + s, z]]) +} + +/// The circle of `radius` about `axis` through `center`, from `reference`. +fn circle(center: [f64; 3], axis: [f64; 3], reference: [f64; 3], radius: f64) -> Curve { + Curve::Arc { center, axis, reference, radius, start: 0.0, end: TAU } +} + +/// An arc of radius 5 about the z axis from `reference`, from `start` to `end`. +fn arc5(reference: [f64; 3], start: f64, end: f64) -> Curve { + Curve::Arc { center: [0.0; 3], axis: Z, reference, radius: 5.0, start, end } +} + +/// The circle of radius 5 about the z axis as two half arcs in one frame. +fn halves() -> Vec { + vec![arc5([1.0, 0.0, 0.0], 0.0, PI), arc5([1.0, 0.0, 0.0], PI, TAU)] +} + +/// The circle of radius 5 about the z axis as a quarter from +x and three quarters from +y. +fn two_frames() -> Vec { + vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([0.0, 1.0, 0.0], 0.0, 3.0 * FRAC_PI_2)] +} + +/// The upper half disc of radius 5 about the z axis, its arc in two quarters. +fn half_disc() -> Vec { + vec![arc5([1.0, 0.0, 0.0], 0.0, FRAC_PI_2), arc5([1.0, 0.0, 0.0], FRAC_PI_2, PI), line([-5.0, 0.0, 0.0], [5.0, 0.0, 0.0])] +} + +/// The rectangle from x = 5 to 8 and z = 0 to 4 in the plane y = 0: area 12, centroid at x 6.5. +fn ring_section() -> Vec { + polygon(&[[5.0, 0.0, 0.0], [8.0, 0.0, 0.0], [8.0, 0.0, 4.0], [5.0, 0.0, 4.0]]) +} + +fn translate(t: [f64; 3]) -> Affine { + Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t } +} + +fn scale_up(s: f64, z: f64) -> Affine { + Affine { m: [[s, 0.0, 0.0], [0.0, s, 0.0], [0.0, 0.0, 1.0]], t: [0.0, 0.0, z] } +} + +#[derive(Clone, Debug)] +enum Op { + PlanarFaces(Vec), + Extrude(Vec, [f64; 3], f64, f64, DraftCorner, bool), + Revolve(Vec, [f64; 3], [f64; 3], f64, bool), + Sweep(Vec, Vec, SweepFrame, bool), + Loft(Vec>, u32, bool), + LoftToPoint(Vec, [f64; 3]), + Stations(Vec, Vec, Ends), + Screw(Vec, [f64; 3], [f64; 3], f64, f64, f64), + /// A screw sweep with a radial law. + ScrewRadial(Vec, [f64; 3], [f64; 3], f64, f64, Vec<(f64, f64)>, f64), +} + +/// What a case must give. +#[derive(Clone, Debug)] +enum Expect { + /// A solid of this volume (relative 1e-9). + Volume(f64), + /// A solid of this volume within this relative error (walls approximated within a tolerance). + About(f64, f64), + /// A sheet of this area (relative 1e-9). + Sheet(f64), + /// A drafted corner refused for this reason, wherever. + Corner(CornerRefusal), +} + +struct Case { + op: Op, + expect: Expect, +} + +/// The frustum of a pyramid or cone: h/3 (A1 + A2 + sqrt(A1 A2)). +fn frustum(a1: f64, a2: f64, h: f64) -> f64 { + h / 3.0 * (a1 + a2 + (a1 * a2).sqrt()) +} + +fn case(name: &str) -> Case { + let c = |op, expect| Case { op, expect }; + let rect = polygon(&[[0.0, 0.0, 0.0], [30.0, 0.0, 0.0], [30.0, 20.0, 0.0], [0.0, 20.0, 0.0]]); + let theta = 5f64.to_radians(); + let disc = || vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 5.0)]; + match name { + // A square of 10 with a hole of radius 2: 100 - 4π. + "planar_faces_with_a_hole" => { + let mut loops = square(0.0, 0.0, 10.0, 0.0); + loops.push(circle([5.0, 5.0, 0.0], Z, [1.0, 0.0, 0.0], 2.0)); + c(Op::PlanarFaces(loops), Expect::Sheet(100.0 - 4.0 * PI)) + } + // V = A h. + "extrude_rectangle" => c(Op::Extrude(rect, Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(6000.0)), + // V = π r² h. + "extrude_circle" => c(Op::Extrude(disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)), + // A circle drawn in two halves (split where another sketch curve ends on it), the + // pieces in the circle's own frame: one wall, as the whole circle sweeps (smlib 7dee48f). + "extrude_circle_in_two_halves" => c(Op::Extrude(halves(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)), + // The same circle as a quarter and three quarters each in its own frame: one wall still, + // its seam in the middle of the longer piece. + "extrude_circle_in_two_frames" => c(Op::Extrude(two_frames(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(250.0 * PI)), + // A half disc whose arc is drawn in two quarters, closed by its diameter: the quarters + // sweep one wall. V = π r² h / 2. + "extrude_half_disc_split_arc" => c(Op::Extrude(half_disc(), Z, 10.0, 0.0, DraftCorner::default(), true), Expect::Volume(125.0 * PI)), + // Leaning in by θ, the walls' offsets overlap at the corners and are trimmed where they + // cross, under every rule: the frustum from side 20 to 20 - 2 h tanθ. + "extrude_square_drafted_round" => { + let top = 20.0 - 2.0 * 10.0 * theta.tan(); + c(Op::Extrude(square(0.0, 0.0, 20.0, 0.0), Z, 10.0, theta, DraftCorner::Round, true), Expect::Volume(frustum(400.0, top * top, 10.0))) + } + "extrude_square_drafted_curvature" => { + let top = 20.0 - 2.0 * 10.0 * theta.tan(); + c(Op::Extrude(square(0.0, 0.0, 20.0, 0.0), Z, 10.0, theta, DraftCorner::Curvature, true), Expect::Volume(frustum(400.0, top * top, 10.0))) + } + // No corner: the cone frustum from radius 5 to 5 - h tanθ. + "extrude_circle_drafted" => { + let r2 = 5.0 - 10.0 * theta.tan(); + c(Op::Extrude(disc(), Z, 10.0, theta, DraftCorner::default(), true), Expect::Volume(frustum(PI * 25.0, PI * r2 * r2, 10.0))) + } + // A strip 2 wide leaning in 10° over 10: the long walls meet across the short ones + // (10 tan 10° > 1) before the far end. + "extrude_draft_wall_closes" => c( + Op::Extrude( + polygon(&[[0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 2.0, 0.0], [0.0, 2.0, 0.0]]), + Z, + 10.0, + 10f64.to_radians(), + DraftCorner::Curvature, + true, + ), + Expect::Corner(CornerRefusal::WallCloses), + ), + // Pappus: A · θ x̄ = 12 · θ · 6.5. + "revolve_rectangle_full" => c(Op::Revolve(ring_section(), [0.0; 3], Z, TAU, true), Expect::Volume(12.0 * TAU * 6.5)), + "revolve_rectangle_quarter" => c(Op::Revolve(ring_section(), [0.0; 3], Z, FRAC_PI_2, true), Expect::Volume(12.0 * FRAC_PI_2 * 6.5)), + // π r² L, along two lines end to end. + "sweep_circle_straight" => c( + Op::Sweep( + vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 2.0)], + vec![line([0.0; 3], [0.0, 0.0, 6.0]), line([0.0, 0.0, 6.0], [0.0, 0.0, 10.0])], + SweepFrame::Follow, + true, + ), + Expect::Volume(PI * 4.0 * 10.0), + ), + // A quarter of a torus: π r² · R θ. + "sweep_circle_along_an_arc" => c( + Op::Sweep( + vec![circle([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0)], + vec![Curve::Arc { center: [0.0; 3], axis: Z, reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }], + SweepFrame::Follow, + true, + ), + Expect::Volume(PI * 10.0 * FRAC_PI_2), + ), + // A bent path (a line, then a quarter arc) needs the curve sweep: π r² times the path's + // length, 10 + 10 π/2, within the walls' fit. + "sweep_along_a_bent_path" => c( + Op::Sweep( + vec![circle([0.0; 3], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], 1.0)], + vec![ + line([0.0; 3], [10.0, 0.0, 0.0]), + Curve::Arc { center: [10.0, 10.0, 0.0], axis: Z, reference: [0.0, -1.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }, + ], + SweepFrame::Follow, + true, + ), + Expect::About(PI * (10.0 + 10.0 * FRAC_PI_2), 1e-4), + ), + // A 4 x 3 rectangle round a closed 60 x 40 rounded rectangle (corner radius 8), from the + // middle of its bottom line: no caps, the walls closing on each other. Pappus: A L, with + // A = 12 and L = 2 (44 + 24) + 4 (π/2) 8. + "sweep_round_a_closed_path" => { + let arc = |c: [f64; 3], r: [f64; 3]| Curve::Arc { center: c, axis: Z, reference: r, radius: 8.0, start: 0.0, end: FRAC_PI_2 }; + let path = vec![ + line([0.0, -20.0, 0.0], [22.0, -20.0, 0.0]), + arc([22.0, -12.0, 0.0], [0.0, -1.0, 0.0]), + line([30.0, -12.0, 0.0], [30.0, 12.0, 0.0]), + arc([22.0, 12.0, 0.0], [1.0, 0.0, 0.0]), + line([22.0, 20.0, 0.0], [-22.0, 20.0, 0.0]), + arc([-22.0, 12.0, 0.0], [0.0, 1.0, 0.0]), + line([-30.0, 12.0, 0.0], [-30.0, -12.0, 0.0]), + arc([-22.0, -12.0, 0.0], [-1.0, 0.0, 0.0]), + line([-22.0, -20.0, 0.0], [0.0, -20.0, 0.0]), + ]; + c( + Op::Sweep(polygon(&[[0.0, -22.0, -1.5], [0.0, -18.0, -1.5], [0.0, -18.0, 1.5], [0.0, -22.0, 1.5]]), path, SweepFrame::Follow, true), + Expect::About(12.0 * (2.0 * (44.0 + 24.0) + 16.0 * PI), 1e-4), + ) + } + // The frustum of a square pyramid: side 20 to 10 over 10. + "loft_two_squares" => { + c(Op::Loft(vec![square(0.0, 0.0, 20.0, 0.0), square(5.0, 5.0, 10.0, 10.0)], 1, true), Expect::Volume(frustum(400.0, 100.0, 10.0))) + } + // The cone frustum: radius 5 to 3 over 10. + "loft_two_circles" => c( + Op::Loft(vec![disc(), vec![circle([0.0, 0.0, 10.0], Z, [1.0, 0.0, 0.0], 3.0)]], 1, true), + Expect::Volume(frustum(25.0 * PI, 9.0 * PI, 10.0)), + ), + // A h / 3. + "loft_to_point_pyramid" => c(Op::LoftToPoint(square(0.0, 0.0, 10.0, 0.0), [5.0, 5.0, 6.0]), Expect::Volume(200.0)), + // A square of 2 carried up 10: 40. + "sweep_stations_prism" => c( + Op::Stations( + square(-1.0, -1.0, 2.0, 0.0), + vec![translate([0.0; 3]), translate([0.0, 0.0, 5.0]), translate([0.0, 0.0, 10.0])], + Ends::Capped, + ), + Expect::Volume(40.0), + ), + // Scaled by s = 1 + z/10 on the way: ∫ (2 s)² dz = 4 · 10 · (2³ - 1) / 3. + "sweep_stations_scaled" => c( + Op::Stations(square(-1.0, -1.0, 2.0, 0.0), vec![scale_up(1.0, 0.0), scale_up(1.5, 5.0), scale_up(2.0, 10.0)], Ends::Capped), + Expect::Volume(4.0 * 10.0 * 7.0 / 3.0), + ), + // A square thread 1 x 1 from radius 5 to 6, pitch 2, two turns: Pappus along the screw + // motion (its rise lies in the profile's plane), A · 2π x̄ · turns, the walls within the + // tolerance of the screw surfaces. + "screw_sweep_square_thread" => c( + Op::Screw(polygon(&[[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 0.0, 1.0], [5.0, 0.0, 1.0]]), [0.0; 3], Z, 2.0, 2.0, 1e-5), + Expect::About(TAU * 5.5 * 2.0, 1e-5), + ), + // The same thread moved out by 0 to 1 over its two turns, linearly: Pappus with the + // centroid at 5.5 + t/2, A · 2π · turns · (5.5 + 1/2). + "screw_sweep_radial_taper" => c( + Op::ScrewRadial( + polygon(&[[5.0, 0.0, 0.0], [6.0, 0.0, 0.0], [6.0, 0.0, 1.0], [5.0, 0.0, 1.0]]), + [0.0; 3], + Z, + 2.0, + 2.0, + vec![(0.0, 0.0), (2.0, 1.0)], + 1e-5, + ), + Expect::About(TAU * 2.0 * 6.0, 1e-5), + ), + _ => panic!("{name}"), + } +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +#[derive(Debug, PartialEq)] +struct Measured { + /// The volume of a solid, the area of a sheet. + size: f64, + counts: [usize; 3], + kinds: BTreeMap, +} + +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(op: &Op) -> crate::Result { + match op { + Op::PlanarFaces(l) => planar_faces(l), + Op::Extrude(p, d, dist, draft, corner, cap) => extrude(p, *d, *dist, *draft, *corner, *cap), + Op::Revolve(p, o, a, angle, cap) => revolve(p, *o, *a, *angle, *cap), + Op::Sweep(p, path, frame, cap) => sweep_placed(p, path, *frame, *cap).map(|(b, _)| b), + Op::Loft(s, d, cap) => loft(s, *d, *cap), + Op::LoftToPoint(s, apex) => loft_to_point(s, *apex), + Op::Stations(p, s, e) => sweep_stations(p, s, *e), + Op::Screw(p, o, a, pitch, turns, tol) => screw_sweep(p, *o, *a, *pitch, *turns, None, *tol), + Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => screw_sweep(p, *o, *a, *pitch, *turns, Some(law), *tol), + } +} + +/// Runs `f` on its own thread: a hang fails the test instead of stopping the run. +fn timed(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::Corner(why), Err(g)) => { + assert!( + matches!(g, Error::Corner { why: w, at } if w == why && at.iter().all(|x| x.is_finite())), + "{name}: {who} failed otherwise: {g:?}" + ) + } + (Expect::Corner(_), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {expect:?}"), + (_, Err(g)) => panic!("{name}: {who} failed: {g}"), + (Expect::Volume(v) | Expect::Sheet(v), Ok(m)) => assert!(rel(m.size, *v) < 1e-9, "{name}: {who} {} vs analytic {v}", m.size), + (Expect::About(v, within), Ok(m)) => assert!(rel(m.size, *v) < *within, "{name}: {who} {} vs analytic {v}", m.size), + } + got +} + +fn solidcore_case(name: &'static str) -> Got { + timed(name, move || { + let c = case(name); + let sheet = matches!(c.expect, Expect::Sheet(_)); + let got = on_core(&c.op).map(|body| { + let size = if sheet { area(&body, 1e-8) } else { volume(&body, 1e-8) }.unwrap_or_else(|e| panic!("{name}: size: {e}")); + let n = counts(&body).unwrap(); + let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); + bounds(&body).unwrap(); + Measured { size, counts: n, kinds: kinds(&rec) } + }); + judge(name, "solidcore", &c.expect, got) + }) +} + +/// The case on the C++ kernel, through the public sweeps forced onto it on this thread. +#[cfg(feature = "native")] +fn cpp_case(name: &str) -> Got { + use crate::{Brep, ffi}; + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let c = case(name); + let r = match &c.op { + Op::PlanarFaces(l) => Brep::planar_faces(l), + Op::Extrude(p, d, dist, draft, corner, cap) => Brep::extrude(p, *d, *dist, *draft, *corner, *cap), + Op::Revolve(p, o, a, angle, cap) => Brep::revolve(p, *o, *a, *angle, *cap), + Op::Sweep(p, path, frame, cap) => Brep::sweep(p, path, *frame, *cap), + Op::Loft(s, d, cap) => Brep::loft(s, *d, *cap), + Op::LoftToPoint(s, apex) => Brep::loft_to_point(s, *apex), + Op::Stations(p, s, e) => Brep::sweep_stations(p, s, *e), + Op::Screw(p, o, a, pitch, turns, tol) => Brep::screw_sweep(p, *o, *a, *pitch, *turns, *tol), + Op::ScrewRadial(p, o, a, pitch, turns, law, tol) => Brep::screw_sweep_radial(p, *o, *a, *pitch, *turns, law, *tol), + }; + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let sheet = matches!(c.expect, Expect::Sheet(_)); + let got = r.map(|b| { + let h = b.cpp().unwrap(); + let size = if sheet { ffi::measure::area(h, 1e-8) } else { ffi::volume(h, 1e-8) }.unwrap(); + let n = ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap()); + let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + Measured { size, counts: n, kinds: kinds(&rec) } + }); + judge(name, "C++", &c.expect, got) +} + +/// solidcore and C++ agree: volume (area) within 1e-9, the same counts and faces by kind, or the +/// same error. +#[cfg(feature = "native")] +fn parity(name: &'static str) { + let cpp = cpp_case(name); + let ours = solidcore_case(name); + match (ours, cpp) { + (Ok(a), Ok(b)) => { + assert!(rel(a.size, b.size) < 1e-9, "{name}: solidcore {} vs C++ {}", a.size, b.size); + assert_eq!(a.counts, b.counts, "{name}: face, edge, vertex counts (solidcore vs C++)"); + assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)"); + } + (a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"), + } +} + +macro_rules! cases { + ($($m:ident $([$pign:meta])? $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::solidcore_case(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + $(#[$pign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::cpp_case(stringify!($m)); + } + } + )*}; +} + +// Cases marked `[ignore = …]` match the closed form on solidcore but not the C++ kernel's +// numbers; `: ignore = …` ones fail on solidcore. +cases!( + planar_faces_with_a_hole, + extrude_rectangle, + extrude_circle, + extrude_circle_in_two_halves, + extrude_circle_in_two_frames, + extrude_half_disc_split_arc, + extrude_square_drafted_round, + extrude_square_drafted_curvature, + extrude_circle_drafted, + extrude_draft_wall_closes, + revolve_rectangle_full, + revolve_rectangle_quarter, + sweep_circle_straight, + sweep_circle_along_an_arc, + sweep_along_a_bent_path, + sweep_round_a_closed_path, + loft_two_squares, + loft_two_circles, + loft_to_point_pyramid, + sweep_stations_prism, + sweep_stations_scaled, + screw_sweep_square_thread, + screw_sweep_radial_taper, +); + +/// Arcs of a profile on one circle sweep one wall face (tangency-labs/smlib 7dee48f): a circle +/// in two pieces extrudes to the faces, edges and vertices of the whole circle (two caps and one +/// cylinder: 3 faces, 3 edges with the seam, 2 vertices) and the area 2πr² + 2πrh; a half disc +/// whose arc is in two quarters to its diameter's plane wall and one cylinder (4 faces, 6 edges, +/// 4 vertices) and the area πr² + πrh + 2rh. On solidcore, and with SMLib built on the C++ kernel. +#[test] +fn arcs_on_one_circle_sweep_one_wall() { + #[cfg(feature = "native")] + let _cpp = crate::route::on_cpp(); + let cases = [ + ("extrude_circle_in_two_halves", [3, 3, 2], 50.0 * PI + 100.0 * PI, true), + ("extrude_circle_in_two_frames", [3, 3, 2], 50.0 * PI + 100.0 * PI, true), + // solidcore sweeps a partial arc, merged or drawn whole, to a surface of extrusion (a + // rational spline in the record) where the C++ kernel makes a cylinder: one wall still. + ("extrude_half_disc_split_arc", [4, 6, 4], 25.0 * PI + 50.0 * PI + 100.0, false), + ]; + let walls = |kinds: &BTreeMap| kinds.iter().filter(|(k, _)| !k.starts_with("plane")).map(|(_, n)| *n).sum::(); + for (name, want_counts, want_area, cylinder_on_core) in cases { + let (counts_got, kinds_got, area_got) = timed(name, move || { + let body = on_core(&case(name).op).unwrap_or_else(|e| panic!("{name}: {e}")); + let (rec, _) = native(&to_arrays(&body).unwrap()).unwrap(); + (counts(&body).unwrap(), kinds(&rec), area(&body, 1e-8).unwrap()) + }); + assert_eq!(counts_got, want_counts, "{name}: solidcore faces, edges, vertices"); + assert_eq!(walls(&kinds_got), 1, "{name}: solidcore curved walls {kinds_got:?}"); + if cylinder_on_core { + assert_eq!(kinds_got.get("cylinder"), Some(&1), "{name}: solidcore walls {kinds_got:?}"); + } + assert!(rel(area_got, want_area) < 1e-9, "{name}: solidcore area {area_got} vs analytic {want_area}"); + #[cfg(feature = "native")] + { + use crate::{Brep, ffi}; + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let r = match case(name).op { + Op::Extrude(p, d, dist, draft, corner, cap) => Brep::extrude(&p, d, dist, draft, corner, cap), + op => panic!("{name}: {op:?}"), + }; + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let b = r.unwrap_or_else(|e| panic!("{name}: C++: {e}")); + let h = b.cpp().unwrap(); + let n = ffi::counts(h).unwrap().map(|x| usize::try_from(x).unwrap()); + let (rec, _) = native(&b.cpp_arrays().unwrap()).unwrap(); + assert_eq!(n, want_counts, "{name}: C++ faces, edges, vertices"); + assert_eq!(walls(&kinds(&rec)), 1, "{name}: C++ curved walls {:?}", kinds(&rec)); + assert_eq!(kinds(&rec).get("cylinder"), Some(&1), "{name}: C++ walls {:?}", kinds(&rec)); + let a = ffi::measure::area(h, 1e-8).unwrap(); + assert!(rel(a, want_area) < 1e-9, "{name}: C++ area {a} vs analytic {want_area}"); + } + } +} + +fn near(a: [f64; 3], b: [f64; 3]) -> bool { + a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9) +} + +fn same_motion(a: &Affine, b: &Affine) -> bool { + a.m.iter().zip(&b.m).all(|(x, y)| near(*x, *y)) && near(a.t, b.t) +} + +/// The motions of a quarter turn about the z axis: by 0, π/4 and π/2. +fn quarter_turn() -> [Affine; 3] { + [0.0, FRAC_PI_2 / 2.0, FRAC_PI_2].map(|a: f64| { + let (c, s) = (a.cos(), a.sin()); + Affine { m: [[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3] } + }) +} + +fn straight_up() -> (Vec, Vec) { + (vec![circle([0.0; 3], Z, [1.0, 0.0, 0.0], 2.0)], vec![line([0.0; 3], [0.0, 0.0, 10.0])]) +} + +fn round_z() -> (Vec, Vec) { + ( + vec![circle([10.0, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0], 1.0)], + vec![Curve::Arc { center: [0.0; 3], axis: Z, reference: [1.0, 0.0, 0.0], radius: 10.0, start: 0.0, end: FRAC_PI_2 }], + ) +} + +fn check_motions(placed: &[Affine; 3], want: &[Affine; 3]) { + assert!(placed.iter().zip(want).all(|(a, b)| same_motion(a, b)), "{placed:?}, expected {want:?}"); +} + +/// `sweep_placed`: along a straight path the profile only moves, by none, half and all of the +/// path; along an arc about z it turns with it about the arc's centre, by none, half and all of +/// the arc's angle. +#[test] +fn sweep_placed_reports_the_motions() { + let (placed, turned) = timed("placed", || { + let (p, up) = straight_up(); + let (q, arc) = round_z(); + (sweep_placed(&p, &up, SweepFrame::Follow, true).unwrap().1, sweep_placed(&q, &arc, SweepFrame::Follow, true).unwrap().1) + }); + check_motions(&placed, &[0.0, 5.0, 10.0].map(|z| translate([0.0, 0.0, z]))); + check_motions(&turned, &quarter_turn()); +} + +/// The same motions on the C++ kernel. +#[cfg(feature = "native")] +#[test] +fn the_cpp_kernel_places_the_profile_alike() { + let _cpp = crate::route::on_cpp(); + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let (p, up) = straight_up(); + let (q, arc) = round_z(); + let placed = crate::Brep::sweep_placed(&p, &up, SweepFrame::Follow, true).unwrap().1; + let turned = crate::Brep::sweep_placed(&q, &arc, SweepFrame::Follow, true).unwrap().1; + crate::sweeps::ON_CPP.with(|c| c.set(false)); + check_motions(&placed, &[0.0, 5.0, 10.0].map(|z| translate([0.0, 0.0, z]))); + check_motions(&turned, &quarter_turn()); +} + +/// A diagonal 10 above a 10 x 10 x 10 box's top, projected down onto the top: the same diagonal +/// at z = 10. +#[test] +fn project_a_line_onto_a_face() { + let got = timed("project", || { + let b = make_box([0.0; 3], [10.0; 3]).unwrap(); + project_curves(&b, [5.0, 5.0, 10.0], 0.01, &[line([1.0, 1.0, 20.0], [9.0, 9.0, 20.0])], [0.0, 0.0, -1.0], 1e-6) + }) + .unwrap(); + assert_eq!(got.len(), 1, "{got:?}"); + let Some((Curve::Nurbs { points, .. }, _)) = got.first() else { panic!("{got:?}") }; + let (a, b) = (points[0], points[points.len() - 1]); + let ends = [[a[0], a[1], a[2]], [b[0], b[1], b[2]]]; + let want = [[1.0, 1.0, 10.0], [9.0, 9.0, 10.0]]; + assert!((near(ends[0], want[0]) && near(ends[1], want[1])) || (near(ends[0], want[1]) && near(ends[1], want[0])), "{ends:?}"); +} + +/// A point of a section curve: the rational B-spline at `u` (de Boor on the weighted points). +fn at(c: &Curve, u: f64) -> [f64; 3] { + let Curve::Nurbs { degree, points, knots } = c else { panic!("{c:?}") }; + let p = *degree; + let span = (p..points.len()).rev().find(|&i| knots[i] <= u).unwrap_or(p); + let mut d: Vec<[f64; 4]> = (0..=p).map(|j| points[span - p + j]).map(|[x, y, z, w]| [x * w, y * w, z * w, w]).collect(); + for r in 1..=p { + for j in (r..=p).rev() { + let i = span - p + j; + let den = knots[i + p + 1 - r] - knots[i]; + let a = if den > 0.0 { (u - knots[i]) / den } else { 0.0 }; + let prev = d[j - 1]; + for (x, q) in d[j].iter_mut().zip(prev) { + *x = (1.0 - a) * q + a * *x; + } + } + } + let [x, y, z, w] = d[p]; + [x / w, y / w, z / w] +} + +/// `n + 1` points along each section curve over its interval. +fn samples(got: &[(Curve, [f64; 2])], n: usize) -> Vec> { + got.iter().map(|(c, [a, b])| (0..=n).map(|i| at(c, a + (b - a) * i as f64 / n as f64)).collect()).collect() +} + +/// The length of the section curves, from chords. +fn length(pts: &[Vec<[f64; 3]>]) -> f64 { + pts.iter().flat_map(|p| p.windows(2)).map(|w| (0..3).map(|i| (w[1][i] - w[0][i]).powi(2)).sum::().sqrt()).sum() +} + +/// A loft from a 10 x 10 square at z = 0 to a 6 x 6 one at z = 10 (degree 1): its front wall +/// runs from y = 0 up to y = 2, so the plane z = 5 cuts it in the line from (9, 1, 5) to +/// (1, 1, 5). +fn square_frustum() -> Vec> { + vec![square(0.0, 0.0, 10.0, 0.0), square(2.0, 2.0, 6.0, 10.0)] +} + +#[test] +fn a_lofted_wall_section_is_the_line_across_it() { + let got = timed("section", || { + let b = loft(&square_frustum(), 1, true).unwrap(); + face_section(&b, [5.0, 1.0, 5.0], 0.01, [0.0, 0.0, 5.0], [0.0, 0.0, 1.0], 1e-6) + }) + .unwrap(); + assert_eq!(got.len(), 1, "{got:?}"); + let p = samples(&got, 8); + let ends = [p[0][0], p[0][8]]; + let want = [[9.0, 1.0, 5.0], [1.0, 1.0, 5.0]]; + assert!((near(ends[0], want[0]) && near(ends[1], want[1])) || (near(ends[0], want[1]) && near(ends[1], want[0])), "{ends:?}"); + assert!(p[0].iter().all(|q| (q[1] - 1.0).abs() < 1e-9 && (q[2] - 5.0).abs() < 1e-9), "{p:?}"); +} + +/// The plane z = 3 through the side of a cylinder of radius 5 and height 10 on the xy plane: the +/// circle of radius 5 at z = 3, 2π·5 long. +#[test] +fn a_cylinder_side_section_is_its_circle() { + let got = timed("section", || { + let b = make_cylinder([0.0; 3], 5.0, 10.0).unwrap(); + face_section(&b, [5.0, 0.0, 5.0], 0.01, [0.0, 0.0, 3.0], [0.0, 0.0, 1.0], 1e-7) + }) + .unwrap(); + let p = samples(&got, 2000); + for q in p.iter().flatten() { + assert!(((q[0] * q[0] + q[1] * q[1]).sqrt() - 5.0).abs() < 1e-6 && (q[2] - 3.0).abs() < 1e-9, "{q:?}"); + } + let len = length(&p); + assert!((len - TAU * 5.0).abs() < 1e-4, "{len} against {}", TAU * 5.0); +} + +/// The plane x = 5 through the top of a 10 mm box: the line (5, 0, 10)-(5, 10, 10) across it. +#[test] +fn a_box_top_section_is_the_line_across_it() { + let got = timed("section", || { + let b = make_box([0.0; 3], [10.0; 3]).unwrap(); + face_section(&b, [5.0, 5.0, 10.0], 0.01, [5.0, 5.0, 5.0], [1.0, 0.0, 0.0], 1e-7) + }) + .unwrap(); + let p = samples(&got, 8); + assert!(p.iter().flatten().all(|q| (q[0] - 5.0).abs() < 1e-9 && (q[2] - 10.0).abs() < 1e-9), "{p:?}"); + let len = length(&p); + assert!((len - 10.0).abs() < 1e-6, "{len} against 10"); +} + +/// The same sections on the C++ kernel: the lofted wall's line along the same points. On a box's +/// top and a cylinder's side, whose analytic intersectors make no UV curves, the capi leaves +/// `bForceUVClassify` FALSE and answers [`Error::Empty`] (solidcore#1769), where the port has the +/// section. +#[cfg(feature = "native")] +#[test] +fn the_cpp_kernel_sections_a_face_alike() { + let _cpp = crate::route::on_cpp(); + use crate::{Brep, ffi}; + type Case = (&'static str, Box Body>, Brep, [[f64; 3]; 3]); + let cases: [Case; 3] = [ + ( + "loft", + Box::new(|| loft(&square_frustum(), 1, true).unwrap()), + { + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let b = Brep::loft(&square_frustum(), 1, true).unwrap(); + crate::sweeps::ON_CPP.with(|c| c.set(false)); + b + }, + [[5.0, 1.0, 5.0], [0.0, 0.0, 5.0], [0.0, 0.0, 1.0]], + ), + ( + "box", + Box::new(|| make_box([0.0; 3], [10.0; 3]).unwrap()), + ffi::make_box(&[0.0; 3], &[10.0; 3]).map(Brep::made).unwrap(), + [[5.0, 5.0, 10.0], [5.0, 5.0, 5.0], [1.0, 0.0, 0.0]], + ), + ( + "cylinder", + Box::new(|| make_cylinder([0.0; 3], 5.0, 10.0).unwrap()), + ffi::make_cylinder(&[0.0; 3], 5.0, 10.0).map(Brep::made).unwrap(), + [[5.0, 0.0, 5.0], [0.0, 0.0, 3.0], [0.0, 0.0, 1.0]], + ), + ]; + for (name, core, cpp, [face, through, n]) in cases { + crate::sweeps::ON_CPP.with(|c| c.set(true)); + let want = cpp.face_section(face, 0.01, through, n, 1e-6); + crate::sweeps::ON_CPP.with(|c| c.set(false)); + let got = face_section(&core(), face, 0.01, through, n, 1e-6); + let (got, want) = match (got, want) { + (Ok(g), Ok(w)) => (g, w), + (Ok(g), Err(Error::Empty)) if name != "loft" => { + assert!(!g.is_empty(), "{name}: the port has the section the capi drops"); + continue; + } + (g, w) => panic!("{name}: {:?} against {:?}", g.err(), w.err()), + }; + assert_eq!(got.len(), want.len(), "{name}: {got:?} against {want:?}"); + let (g, w) = (samples(&got, 64), samples(&want, 64)); + for (a, b) in g.iter().flatten().zip(w.iter().flatten()) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9), "{name}: {a:?} against {b:?}"); + } + } +} + +/// The capi's argument checks, before any kernel call. +#[test] +fn hostile_arguments_are_refused() { + let sq = square(0.0, 0.0, 1.0, 0.0); + let d = DraftCorner::default(); + for dist in [0.0, -1.0, f64::NAN, 1e10] { + assert_eq!(extrude(&sq, Z, dist, 0.0, d, true).err(), Some(Error::Argument), "distance {dist}"); + } + assert_eq!(extrude(&sq, Z, 1.0, FRAC_PI_2, d, true).err(), Some(Error::Argument), "a right-angled draft"); + assert_eq!(extrude(&sq, [0.0; 3], 1.0, 0.0, d, true).err(), Some(Error::Argument), "no direction"); + assert_eq!(extrude(&[], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument), "no profile"); + assert_eq!(extrude(&[line([0.0; 3], [0.0; 3])], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument), "a point"); + assert_eq!(extrude(&[line([f64::NAN; 3], [1.0; 3])], Z, 1.0, 0.0, d, true).err(), Some(Error::Argument)); + // A drafted profile is one loop, square to the direction. + assert_eq!(extrude(&sq[..3], Z, 1.0, 0.1, d, true).err(), Some(Error::Argument), "an open profile"); + assert_eq!(extrude(&sq, [1.0, 0.0, 0.0], 1.0, 0.1, d, true).err(), Some(Error::Argument), "along the profile"); + let bad_arc = Curve::Arc { center: [0.0; 3], axis: Z, reference: Z, radius: 1.0, start: 0.0, end: 1.0 }; + assert_eq!(extrude(&[bad_arc], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "reference along the axis"); + let bad_nurbs = Curve::Nurbs { degree: 1, points: vec![[0.0; 4], [1.0, 0.0, 0.0, 1.0]], knots: vec![0.0, 0.0, 1.0, 1.0] }; + assert_eq!(extrude(&[bad_nurbs], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "a zero weight"); + let unclamped = Curve::Nurbs { degree: 1, points: vec![[0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 0.0, 1.0]], knots: vec![0.0, 0.5, 1.0, 1.0] }; + assert_eq!(extrude(&[unclamped], Z, 1.0, 0.0, d, false).err(), Some(Error::Argument), "unclamped knots"); + assert_eq!(revolve(&sq, [0.0; 3], Z, 0.0, true).err(), Some(Error::Argument), "no angle"); + assert_eq!(revolve(&sq, [0.0; 3], Z, 7.0, true).err(), Some(Error::Argument), "more than a turn"); + assert_eq!(revolve(&sq, [f64::INFINITY; 3], Z, 1.0, true).err(), Some(Error::Argument)); + assert_eq!(loft(std::slice::from_ref(&sq), 1, true).err(), Some(Error::Argument), "one section"); + assert_eq!(loft(&[sq.clone(), sq.clone()], 4, true).err(), Some(Error::Argument), "degree 4"); + assert_eq!(loft_to_point(&sq, [0.5, 0.5, 0.0]).err(), Some(Error::Argument), "apex in the base's plane"); + assert_eq!(loft_to_point(&[circle([0.0; 3], Z, [1.0, 0.0, 0.0], 1.0)], Z).err(), Some(Error::Argument), "not lines"); + let flat = Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]], t: [0.0; 3] }; + assert_eq!(sweep_stations(&sq, &[translate([0.0; 3]), flat], Ends::Capped).err(), Some(Error::Argument), "a singular map"); + assert_eq!(sweep_stations(&sq, &[translate([0.0; 3])], Ends::Capped).err(), Some(Error::Argument), "one station"); + assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 0.0, 1.0, None, 1e-3).err(), Some(Error::Argument), "no pitch"); + assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 300.0, None, 1e-3).err(), Some(Error::Argument), "too many turns"); + assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[(0.5, 0.0), (0.2, 0.0)]), 1e-3).err(), Some(Error::Argument), "a law going back"); + assert_eq!(screw_sweep(&sq, [0.0; 3], Z, 1.0, 1.0, Some(&[]), 1e-3).err(), Some(Error::Argument), "an empty law"); + let b = make_box([0.0; 3], [1.0; 3]).unwrap(); + assert_eq!(project_curves(&b, [0.5; 3], 0.01, &sq, [0.0; 3], 1e-6).err(), Some(Error::Argument), "no direction"); + assert_eq!(project_curves(&b, [0.5; 3], 0.0, &sq, Z, 1e-6).err(), Some(Error::Argument), "no pick tolerance"); + assert_eq!(face_section(&b, [0.5; 3], 0.01, [0.5; 3], [0.0; 3], 1e-6).err(), Some(Error::Argument), "no plane normal"); +} diff --git a/crates/smlib/src/core/transform.rs b/crates/smlib/src/core/transform.rs new file mode 100644 index 0000000000..2c28340a87 --- /dev/null +++ b/crates/smlib/src/core/transform.rs @@ -0,0 +1,32 @@ +//! Copies and affine transforms on solidcore: the C API's `smlib_brep_copy` and +//! `smlib_brep_transform`, ported from the fork's `capi/src/smlib_c.cpp`. +//! +//! As there, a transform works on a copy of its body (`SmApiBrepCopy`), which +//! `SmBrep::Transform(double[4][4], bScaleTol)` (solidcore's `transform_matrix`, solidcore#1570) +//! then moves in place, tolerances scaling with the model. The matrix is the capi's: rows 0-2 the +//! images of x, y and z, row 3 the translation (row-vector convention, `p' = p·A + t`); the +//! fourth column is not read. + +use super::Body; +use super::blend::{on_copy, status}; +use crate::{Error, Result}; +use solidcore::topology::brep::mirror::transform_matrix; + +/// `smlib_brep_copy`: `SmApiBrepCopy`. +pub(crate) fn copy(body: &Body) -> Result { + on_copy("SmApiBrepCopy", body, |_, b| Ok(Ok(b))) +} + +/// `smlib_brep_transform`: `SmBrep::Transform(matrix, TRUE)` on a copy of `body`. Every entry of +/// the matrix must be finite (`SMLIB_ERR_ARG`). +pub(crate) fn transform(body: &Body, matrix: &[[f64; 4]; 4]) -> Result { + if !matrix.iter().flatten().all(|x| x.is_finite()) { + return Err(Error::Argument); + } + on_copy("SmBrep::Transform", body, |k, mut b| { + Ok(match transform_matrix(k, &mut b, matrix, true) { + Ok(()) => Ok(b), + Err(e) => Err(status(e)), + }) + }) +} diff --git a/crates/smlib/src/core/transform_tests.rs b/crates/smlib/src/core/transform_tests.rs new file mode 100644 index 0000000000..afc373d890 --- /dev/null +++ b/crates/smlib/src/core/transform_tests.rs @@ -0,0 +1,328 @@ +//! Copies and affine transforms on solidcore, against closed forms: every result keeps its counts, +//! its volume scales by the matrix's |det|, its bounds are the moved box's, and it exports to a +//! record that validates. With SMLib built, the same case on the C++ kernel gives the same +//! volume, counts, bounds and faces by surface kind, or the same error (parity only: the +//! expected values are the analytic ones). + +use std::collections::BTreeMap; +use std::f64::consts::PI; + +use super::transform::{copy, transform}; +use super::{Body, bounds, counts, make_box, make_cylinder, to_arrays, volume}; +use crate::Error; +use crate::export::native; + +/// The identity, as the capi reads a matrix: rows 0-2 the images of x, y and z, row 3 the move. +const ID: [[f64; 4]; 4] = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]; + +#[derive(Clone, Copy, Debug)] +enum Shape { + /// A box from its lower corner. + Box([f64; 3], [f64; 3]), + /// A cylinder about +Z from its base centre. + Cylinder([f64; 3], f64, f64), +} + +impl Shape { + fn core(self) -> Body { + match self { + Shape::Box(o, s) => make_box(o, s).unwrap(), + Shape::Cylinder(o, r, h) => make_cylinder(o, r, h).unwrap(), + } + } + + fn volume(self) -> f64 { + match self { + Shape::Box(_, s) => s[0] * s[1] * s[2], + Shape::Cylinder(_, r, h) => PI * r * r * h, + } + } + + fn counts(self) -> [usize; 3] { + match self { + Shape::Box(..) => [6, 12, 8], + Shape::Cylinder(..) => [3, 3, 2], + } + } + + /// The corners of its bounding box. + fn corners(self) -> Vec<[f64; 3]> { + let (lo, hi) = match self { + Shape::Box(o, s) => (o, [o[0] + s[0], o[1] + s[1], o[2] + s[2]]), + Shape::Cylinder(o, r, h) => ([o[0] - r, o[1] - r, o[2]], [o[0] + r, o[1] + r, o[2] + h]), + }; + (0..8) + .map(|k| [if k & 1 == 0 { lo[0] } else { hi[0] }, if k & 2 == 0 { lo[1] } else { hi[1] }, if k & 4 == 0 { lo[2] } else { hi[2] }]) + .collect() + } +} + +/// `p·A + t`. +fn apply(m: &[[f64; 4]; 4], p: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|j| p[0] * m[0][j] + p[1] * m[1][j] + p[2] * m[2][j] + m[3][j]) +} + +fn det(m: &[[f64; 4]; 4]) -> f64 { + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]) +} + +fn rotation_z(a: f64) -> [[f64; 4]; 4] { + let (s, c) = a.sin_cos(); + [[c, s, 0.0, 0.0], [-s, c, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] +} + +struct Case { + shape: Shape, + matrix: [[f64; 4]; 4], + /// `None`: the closed form (volume times |det|, the moved box, the same counts). + fails: Option, +} + +const BOX: Shape = Shape::Box([1.0, 2.0, 3.0], [10.0, 20.0, 30.0]); +const CYL: Shape = Shape::Cylinder([0.0, 0.0, 0.0], 5.0, 12.0); + +fn case(name: &str) -> Case { + let ok = |shape, matrix| Case { shape, matrix, fails: None }; + let mut moved = ID; + moved[3] = [5.0, -7.0, 11.0, 1.0]; + match name { + "identity" => ok(BOX, ID), + "translation" => ok(BOX, moved), + "rotation_about_z" => ok(BOX, rotation_z(PI / 2.0)), + "uniform_scale" => ok(BOX, [[2.0, 0.0, 0.0, 0.0], [0.0, 2.0, 0.0, 0.0], [0.0, 0.0, 2.0, 0.0], [1.0, 1.0, 1.0, 1.0]]), + "mirror_in_x" => ok(BOX, [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0; 4]]), + "non_uniform_scale" => ok(BOX, [[2.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.5, 0.0], [0.0; 4]]), + "shear" => ok(BOX, [[1.0, 0.5, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [2.0, 3.0, 4.0, 1.0]]), + "cylinder_moved" => ok(CYL, moved), + "cylinder_rotated" => ok(CYL, rotation_z(0.3)), + "cylinder_mirrored" => ok(CYL, [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, -1.0, 0.0], [0.0; 4]]), + "cylinder_scaled" => ok(CYL, [[3.0, 0.0, 0.0, 0.0], [0.0, 3.0, 0.0, 0.0], [0.0, 0.0, 3.0, 0.0], [0.0; 4]]), + _ => panic!("no case {name}"), + } +} + +#[derive(Debug, PartialEq)] +struct Measured { + volume: f64, + bounds: ([f64; 3], [f64; 3]), + counts: [usize; 3], + kinds: BTreeMap, +} + +type Got = std::result::Result; + +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 rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +/// The moved shape's bounding box: a box's moved corners bound it exactly; a cylinder's only for +/// the matrices here (a rotation about its axis, a move, a mirror in z, a uniform scale), whose +/// moved circle's box is the moved square's turned back to the axes. +fn expected_bounds(c: &Case) -> ([f64; 3], [f64; 3]) { + let pts: Vec<[f64; 3]> = match c.shape { + Shape::Cylinder(o, r, h) => { + let s = (c.matrix[0][0] * c.matrix[0][0] + c.matrix[0][1] * c.matrix[0][1]).sqrt(); + let centre = [apply(&c.matrix, o), apply(&c.matrix, [o[0], o[1], o[2] + h])]; + centre.iter().flat_map(|p| [[p[0] - r * s, p[1] - r * s, p[2]], [p[0] + r * s, p[1] + r * s, p[2]]]).collect() + } + s => s.corners().into_iter().map(|p| apply(&c.matrix, p)).collect(), + }; + let mut lo = [f64::INFINITY; 3]; + let mut hi = [f64::NEG_INFINITY; 3]; + for p in pts { + for k in 0..3 { + lo[k] = lo[k].min(p[k]); + hi[k] = hi[k].max(p[k]); + } + } + (lo, hi) +} + +fn judge(name: &str, who: &str, got: Got) -> Got { + let c = case(name); + match (&c.fails, &got) { + (Some(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"), + (Some(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"), + (None, Err(g)) => panic!("{name}: {who} failed: {g}"), + (None, Ok(m)) => { + let v = c.shape.volume() * det(&c.matrix).abs(); + assert!(rel(m.volume, v) < 1e-9, "{name}: {who} volume {} vs analytic {v}", m.volume); + assert_eq!(m.counts, c.shape.counts(), "{name}: {who} counts"); + let (lo, hi) = expected_bounds(&c); + let near = |a: [f64; 3], b: [f64; 3]| a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6); + assert!(near(m.bounds.0, lo) && near(m.bounds.1, hi), "{name}: {who} bounds {:?}, expected {lo:?} {hi:?}", m.bounds); + } + } + got +} + +fn solidcore_case(name: &str) -> Got { + let c = case(name); + let got = transform(&c.shape.core(), &c.matrix).map(|b| { + let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); + Measured { volume: volume(&b, 1e-8).unwrap(), bounds: bounds(&b).unwrap(), counts: counts(&b).unwrap(), kinds: kinds(&rec) } + }); + judge(name, "solidcore", got) +} + +/// The case on the C++ kernel, called directly (whatever the choice). +#[cfg(feature = "native")] +fn cpp_case(name: &str) -> Got { + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let c = case(name); + let body = match c.shape { + Shape::Box(o, s) => ffi::make_box(&o, &s).unwrap(), + Shape::Cylinder(o, r, h) => ffi::make_cylinder(&o, r, h).unwrap(), + }; + let got = ffi::transform(&body, &c.matrix).map_err(error).map(|h| { + let volume = ffi::volume(&h, 1e-8).unwrap(); + let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap()); + let bx = ffi::measure::bounds(&h).unwrap(); + let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + Measured { volume, bounds: bx, counts: n, kinds: kinds(&rec) } + }); + judge(name, "C++", got) +} + +/// solidcore and C++ agree: volume within 1e-9, the same counts, bounds and faces by kind, or the +/// same error. +#[cfg(feature = "native")] +fn parity(name: &str) { + let (ours, cpp) = (solidcore_case(name), cpp_case(name)); + match (ours, cpp) { + (Ok(a), Ok(b)) => { + assert!(rel(a.volume, b.volume) < 1e-9, "{name}: solidcore {} vs C++ {}", a.volume, b.volume); + assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)"); + assert_eq!(a.kinds, b.kinds, "{name}: faces by surface kind (solidcore vs C++)"); + let near = |x: [f64; 3], y: [f64; 3]| x.iter().zip(&y).all(|(p, q)| (p - q).abs() < 1e-6); + assert!(near(a.bounds.0, b.bounds.0) && near(a.bounds.1, b.bounds.1), "{name}: bounds {:?} vs C++ {:?}", a.bounds, b.bounds); + } + (a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"), + } +} + +macro_rules! cases { + ($($m:ident $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::solidcore_case(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::cpp_case(stringify!($m)); + } + } + )*}; +} + +cases!( + identity, + translation, + rotation_about_z, + uniform_scale, + mirror_in_x, + non_uniform_scale, + shear, + cylinder_moved, + cylinder_rotated, + cylinder_mirrored, + cylinder_scaled, +); + +/// The capi's argument check, before any kernel call: every entry finite. +#[test] +fn hostile_matrices_are_refused() { + let b = BOX.core(); + for (i, j) in [(0, 0), (1, 2), (3, 1), (2, 3)] { + for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut m = ID; + m[i][j] = x; + assert_eq!(transform(&b, &m).err(), Some(Error::Argument), "m[{i}][{j}] = {x}"); + } + } +} + +/// A singular or huge matrix never panics: it gives a body or an error. +#[test] +fn degenerate_matrices_do_not_crash() { + let b = BOX.core(); + let zero = [[0.0; 4]; 4]; + let flat = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0], [0.0; 4]]; + let huge = [[1e300, 0.0, 0.0, 0.0], [0.0, 1e300, 0.0, 0.0], [0.0, 0.0, 1e300, 0.0], [0.0; 4]]; + for m in [zero, flat, huge] { + let _ = transform(&b, &m); + } +} + +/// The body moved, and the body copied, are never changed. +#[test] +fn the_body_stays_as_it_was() { + let b = BOX.core(); + let moved = transform(&b, &rotation_z(1.0)).unwrap(); + let c = copy(&b).unwrap(); + drop(moved); + for x in [&b, &c] { + assert_eq!(counts(x).unwrap(), [6, 12, 8]); + assert!(rel(volume(x, 1e-8).unwrap(), 6000.0) < 1e-12); + assert_eq!(bounds(x).unwrap(), ([1.0, 2.0, 3.0], [11.0, 22.0, 33.0])); + } +} + +/// Face and edge tags (`Brep::tagged`) through a copy and a transform on solidcore, as on +/// C++: the box is tagged on the C++ kernel, its solidcore body converted from that and tagged the +/// same way, and the two results' histories must be the same. +#[cfg(feature = "native")] +mod history { + use crate::{Brep, ffi}; + + fn tagged_box() -> Brep { + crate::init().unwrap(); + Brep::made(ffi::make_box(&[0.0; 3], &[20.0, 30.0, 10.0]).unwrap()).tagged(0).unwrap() + } + + #[test] + fn tags_cross_a_copy() { + let _cpp = crate::route::on_cpp(); + let a = tagged_box(); + let cpp = Brep::made(ffi::copy(a.cpp().unwrap()).unwrap()).history().unwrap(); + let core = Brep::made_core(super::copy(a.core().unwrap()).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical()); + } + + #[test] + fn tags_cross_a_mirror_and_a_shear() { + let _cpp = crate::route::on_cpp(); + let a = tagged_box(); + let mirror = [[-1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0; 4]]; + let shear = [[1.0, 0.5, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [2.0, 3.0, 4.0, 1.0]]; + for m in [mirror, shear] { + let cpp = Brep::made(ffi::transform(a.cpp().unwrap(), &m).unwrap()).history().unwrap(); + let core = Brep::made_core(super::transform(a.core().unwrap(), &m).unwrap()).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical(), "{m:?}"); + } + } +} diff --git a/crates/smlib/src/core/trim.rs b/crates/smlib/src/core/trim.rs new file mode 100644 index 0000000000..53ba9ab69a --- /dev/null +++ b/crates/smlib/src/core/trim.rs @@ -0,0 +1,862 @@ +//! Untrim and extend on solidcore: the C API's `smlib_untrim_face` and `smlib_extend_sheet`, +//! ported from the fork's `capi/src/smlib_untrim.cpp` (`face_at`, `loop_distance`, +//! `copy_attributes`, `analytic`, `span_of`, `pieces_of`, `is_disc`, `rebuild_hole`) and +//! `capi/src/smlib_extend.cpp` (`on_side`, `eval`, `nearest_edge`, `is_boundary`, `side_edge`, +//! `across`, `cone_continued`, `same_patch`). +//! +//! A face restored towards its surface. Picked inner loops (holes) are taken away with +//! `SmFace::DeleteInnerLoop` on a copy where they are lamina; the outer boundary goes back to a +//! parameter box of the surface (`SmBrep::CreateFaceFromSurface`), and the holes kept are rebuilt +//! in that face from their own edges' curves and intervals (`MakeVertexLoop`, `MakeEdgeInFace`: +//! closing a loop splits off the face inside it, which is deleted). The new face and the rebuilt +//! edges take the attributes of the face and edges they stand for, so the history tags carry +//! through. +//! +//! A sheet is extended by SMLib's extension of its surface (`CreateExtendedSurface`: its end +//! spans continued, or a G1 or G2 run-off), the distance asked of it corrected until the +//! extension measures the distance across the side's middle; `CopyAndAddAnalytics` gives the +//! result back its analytic kind, and a cone or cylinder continued naturally stays the analytic +//! widened. The new face and each of its sides take the attributes of the face and side they +//! continue. +//! +//! An untrim of a face out of its body (`in_place` false) copies the face into a body of its own +//! through `SmBrep::CopyFaces` with the merge (solidcore#1872), which keeps its attributes. + +use std::sync::PoisonError; + +use solidcore::EntityRef; +use solidcore::curve::curve::Curve; +use solidcore::geom::extent2d::Extent2d; +use solidcore::geom::geom_utility::p2::ContinuityType; +use solidcore::geom::object::Shape; +use solidcore::geom::tol::USE_DEFAULT; +use solidcore::geom::vector2d::Point2; +use solidcore::geom::vector3d::{Point3, Vec3}; +use solidcore::kernel::Kernel; +use solidcore::nurbs::b_spline_surface::p1::SurfParamType; +use solidcore::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags; +use solidcore::nurbs::b_spline_surface_n_lib::p2::OrientType; +use solidcore::query::api_queries::{edge_closest_point, face_closest_point_in_shape, face_internal_point}; +use solidcore::query::brep::{BrepQuery, LiveBrepObjects}; +use solidcore::surface::cone::p1::Cone; +use solidcore::surface::surface::Surface; +use solidcore::surface::surface::SurfaceKind; +use solidcore::topology::brep::p1 as brep_p1; +use solidcore::topology::brep::p2::{CopyNotifyState, copy_source_ref}; +use solidcore::topology::brep::p3::{DeleteFaceOptions, delete_face, new_brep, with_kernel_callbacks}; +use solidcore::topology::brep::p5::{EdgeInFace, make_edge_in_face}; +use solidcore::topology::brep::p7::copy_faces_from; +use solidcore::topology::brep::p8::create_face_from_surface; +use solidcore::topology::brep::p9::make_vertex_loop; +use solidcore::topology::edgeuse::p1 as eu_p1; +use solidcore::topology::edgeuse::p3::get_or_create_uv_trim_curve; +use solidcore::topology::face::{p1 as face_p1, p2 as face_p2, p4 as face_p4}; +use solidcore::topology::loopuse::{loop_edges, loop_loopuses, notify_ref}; +use solidcore::topology::topology_traverser::Marks; +use solidcore::topology::{Brep, BrepId, EdgeId, FaceId, LoopId, edge}; + +use super::blend::{p3, status}; +use super::sweep::build_faces; +use super::{Body, answer, run}; +use crate::trim::Continuation; +use crate::{Error, Result}; +use solidcore::topology::brep::p3::brep_copy; + +/// `MAX_HOLE_POINTS`. +const MAX_HOLE_POINTS: usize = 10_000; + +/// `SMLIB_ERR_EXCEPTION`'s stand-in: a rebuilt hole that did not close as the capi expects. +fn hole_refused() -> Error { + Error::Exception +} + +/// The capi's statuses as SolidCraft errors: an operation's own result. +type Out = std::result::Result; + +const ONE: BrepId = BrepId::from_index(0); + +/// `face_at`: the face of `b` nearest `p`, within `tol` (`SmApiFaceClosestPoint`; a face whose +/// query fails is skipped). +fn face_at(k: &mut Kernel, b: &mut Brep, p: Point3, tol: f64) -> solidcore::Result> { + let all = brep_p1::faces(b, None)?; + let mut shapes = Vec::with_capacity(all.len()); + for &f in &all { + shapes.push(answer(Shape::new(k, &(ONE, &*b), &[], &[(ONE, f)], &[], &[]))?); + } + let query = BrepQuery::of_brep(b); + let identity = query.effective_identity(); + let objects = LiveBrepObjects::new(b, &identity, None)?; + let (mut best, mut d0) = (None, f64::INFINITY); + for (&f, shape) in all.iter().zip(&shapes) { + let Some(shape) = shape else { continue }; + if let Some(c) = answer(face_closest_point_in_shape(k, shape, &objects, (ONE, f), p))? + && c.distance.is_finite() + && c.distance < d0 + { + d0 = c.distance; + best = Some(f); + } + } + Ok(best.filter(|_| d0 <= tol)) +} + +/// `loop_distance`: the distance from `p` to the nearest edge of loop `l`. +fn loop_distance(k: &Kernel, b: &Brep, l: LoopId, p: Point3) -> solidcore::Result { + let mut best = f64::INFINITY; + for e in loop_edges(b, l)?.into_iter().flatten() { + if let Some(c) = answer(edge_closest_point(k, b, e, p))? + && c.closest.distance.is_finite() + { + best = best.min(c.closest.distance); + } + } + Ok(best) +} + +/// `copy_attributes`: `to` (in `into`) takes a copy of each attribute of `from` (in `src`). +fn copy_attributes(src: &Brep, from: EntityRef, into: &mut Brep, to: EntityRef) -> solidcore::Result<()> { + let copies: Vec<_> = src.entity_attributes(from).into_iter().map(|(_, a)| a.make_copy()).collect(); + for a in copies { + into.add_attribute(to, a)?; + } + Ok(()) +} + +/// [`copy_attributes`] within one body. +fn copy_attributes_within(b: &mut Brep, from: EntityRef, to: EntityRef) -> solidcore::Result<()> { + let copies: Vec<_> = b.entity_attributes(from).into_iter().map(|(_, a)| a.make_copy()).collect(); + for a in copies { + b.add_attribute(to, a)?; + } + Ok(()) +} + +/// `span_of`: the box of the face's outer loop in the surface's parameters, the union of the +/// tight boxes of its edgeuses' UV trim curves (made where the face has none attached yet). +fn span_of(k: &Kernel, b: &mut Brep, outer: LoopId) -> solidcore::Result> { + let (lu, _) = loop_loopuses(b, outer)?; + let eus = eu_p1::loopuse_edgeuses(b, lu)?; + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + let mut any = false; + for eu in eus { + let fetched = get_or_create_uv_trim_curve(k, b, eu, false, false, true)?; + if fetched.status.is_err() { + return Ok(None); + } + let (Some(uv), _) = eu_p1::uv_trim_curve(b, eu)? else { return Ok(None) }; + let Some(e) = answer(uv.calculate_tight_bounding_box(k, &uv.natural_interval()))? else { return Ok(None) }; + let (mn, mx) = (e.min(), e.max()); + lo = [lo[0].min(mn.x), lo[1].min(mn.y)]; + hi = [hi[0].max(mx.x), hi[1].max(mx.y)]; + any = true; + } + if !any || !(hi[0] > lo[0]) || !(hi[1] > lo[1]) || !(lo[0] + lo[1] + hi[0] + hi[1]).is_finite() { + return Ok(None); + } + Ok(Some(Extent2d::from_bounds(lo[0], lo[1], hi[0], hi[1]))) +} + +/// One edge of a hole to rebuild: its edge, how the loop runs along it, its ends as the loop +/// runs, its curve and interval (`Piece`). +struct Piece { + edge: EdgeId, + orient: OrientType, + from: Point3, + to: Point3, + curve: Curve, + interval: solidcore::geom::extent1d::Extent1d, +} + +/// `pieces_of`; `None` where the capi gives up. +fn pieces_of(b: &Brep, l: LoopId) -> solidcore::Result>> { + let (lu, _) = loop_loopuses(b, l)?; + let mut out = Vec::new(); + for eu in eu_p1::loopuse_edgeuses(b, lu)? { + let Some(e) = eu_p1::edge(b, eu)? else { return Ok(None) }; + let ed = b.edge(e)?; + let Some(curve) = ed.curve.clone() else { return Ok(None) }; + let (Some(v0), Some(v1)) = edge::start_end_vertices(b, e)? else { return Ok(None) }; + let same = eu_p1::orientation(b, eu)? == OrientType::Same; + let (p0, p1) = (b.vertex(v0)?.point, b.vertex(v1)?.point); + let (from, to) = if same { (p0, p1) } else { (p1, p0) }; + out.push(Piece { edge: e, orient: if same { OrientType::Same } else { OrientType::Opposite }, from, to, curve, interval: ed.interval }); + } + Ok((!out.is_empty()).then_some(out)) +} + +/// `is_disc`: whether `f` is bounded by one loop of exactly the edges `made`. +fn is_disc(b: &Brep, f: FaceId, made: &[EdgeId]) -> solidcore::Result { + let loops = face_p2::loops(b, f, false, None)?; + let [l] = loops.as_slice() else { return Ok(false) }; + let edges: Vec = loop_edges(b, *l)?.into_iter().flatten().collect(); + Ok(edges.len() == made.len() && edges.iter().all(|e| made.contains(e))) +} + +/// `rebuild_hole`: the hole `pieces` (edges of `src`) rebuilt in `face` of `r`: vertices and +/// edges made in the face, the face the closed loop splits off inside it deleted. `face` is the +/// face that keeps the rest. +fn rebuild_hole(k: &mut Kernel, src: Option<&Brep>, r: &mut Brep, face: &mut FaceId, pieces: &[Piece]) -> solidcore::Result> { + let Some(first_piece) = pieces.first() else { return Ok(Err(hole_refused())) }; + let editing = r.editing_enabled(); + r.set_editing_enabled(true); + let tol = r.tolerance(); + // Each edit notifies through the kernel's callbacks with the body's own attributes + // (`MakeEdgeInFace` does so itself), so the history tags follow. + let vertex_loop = |k: &mut Kernel, r: &mut Brep, face: FaceId, at: Point3| { + with_kernel_callbacks(k, r, |_, r, env| make_vertex_loop(r, face, at, OrientType::Opposite, env)) + }; + let done = (|| -> solidcore::Result> { + let (_, first) = match vertex_loop(k, r, *face, first_piece.from) { + Ok(v) => v, + Err(e) => return Ok(Err(status(e))), + }; + let mut at = first; + let mut made = Vec::with_capacity(pieces.len()); + let mut split = None; + for (i, p) in pieces.iter().enumerate() { + let last = i + 1 == pieces.len(); + let next = if last { + first + } else { + match vertex_loop(k, r, *face, p.to) { + Ok((_, v)) => v, + Err(e) => return Ok(Err(status(e))), + } + }; + let curve = p.curve.copy(k)?; + let mut out = EdgeInFace::default(); + if let Err(e) = make_edge_in_face(k, r, true, Some(*face), Some(at), Some(next), curve, None, p.interval, p.orient, tol, &mut out, None) { + return Ok(Err(status(e))); + } + let Some(e) = out.edge else { return Ok(Err(Error::Empty)) }; + match src { + Some(s) => copy_attributes(s, EntityRef::Edge(p.edge), r, EntityRef::Edge(e))?, + None => copy_attributes_within(r, EntityRef::Edge(p.edge), EntityRef::Edge(e))?, + } + made.push(e); + if let Some(nf) = out.face { + if !last || split.is_some() { + return Ok(Err(hole_refused())); + } + split = Some(nf); + } + at = next; + } + let Some(split) = split else { return Ok(Err(hole_refused())) }; + // One of the two faces is the disc inside the hole: it goes, its edges stay on the other. + let disc = if is_disc(r, split, &made)? { + split + } else if is_disc(r, *face, &made)? { + *face + } else { + return Ok(Err(hole_refused())); + }; + let rest = if disc == split { *face } else { split }; + let deleted = with_kernel_callbacks(k, r, |k, r, env| { + let mut marks = Marks::default(); + let options = DeleteFaceOptions { delete_edges_vertices: false, ..DeleteFaceOptions::default() }; + delete_face(k, r, env, &mut marks, disc, options) + }); + if let Err(e) = deleted { + return Ok(Err(status(e))); + } + *face = rest; + Ok(Ok(())) + })(); + r.set_editing_enabled(editing); + r.refresh_cached_topology(); + done +} + +/// The orientation of `f`'s upward faceuse. +fn upward(b: &Brep, f: FaceId) -> solidcore::Result> { + Ok(match face_p2::upward_faceuse(b, f)? { + Some(fu) => Some(b.faceuse(fu)?.orientation), + None => None, + }) +} + +/// `smlib_untrim_face`: the face of `body` nearest `point` (within `tol`) restored towards its +/// surface: the holes nearest `holes` (all of them for `None`) taken away and, with `outer`, its +/// outer boundary back on the surface's parameter box. The face alone, or with `in_place` the +/// whole body with it replaced. +pub(crate) fn untrim_face(body: &Body, point: [f64; 3], tol: f64, holes: Option<&[[f64; 3]]>, outer: bool, in_place: bool) -> Result { + let n_holes = holes.map(<[_]>::len); + if !point.iter().all(|x| x.is_finite()) + || !tol.is_finite() + || !(tol > 0.0) + || n_holes.is_some_and(|n| n > MAX_HOLE_POINTS) + || holes.is_some_and(|h| !h.iter().flatten().all(|x| x.is_finite())) + || (n_holes == Some(0) && !outer) + { + return Err(Error::Argument); + } + if body.0.is_poisoned() { + return Err(Error::Internal("smlib_untrim_face: the body was left unfinished by an earlier panic".to_owned())); + } + let at = p3(point); + run("smlib_untrim_face", |k| { + let mut whole = { + let g = body.0.lock().unwrap_or_else(PoisonError::into_inner); + super::dump::input(k, "brep", &g); + brep_copy(k, &g)? + }; + // The face to untrim, in a body of its own (its hole edges lamina there) or in the copy + // of the whole body. + let (mut w, f) = if in_place { + let f = face_at(k, &mut whole, at, tol)?; + (whole, f) + } else { + let Some(src) = face_at(k, &mut whole, at, tol)? else { return Ok(Err(Error::NoMatch { point: 0 })) }; + let mut w = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + w.set_tolerance(whole.tolerance()); + // Through SmMerge, with both bodies' attributes in the notifications, so the copy + // carries its source's history tags (`SM_NO_COPY`). + let copied = { + let mut state = CopyNotifyState::with_attributes_of(&mut whole, &mut w)?; + let this_face = |f: FaceId| notify_ref(EntityRef::Face(f)); + let copied = copy_faces_from(k, &mut whole, &[src], &mut w, true, ©_source_ref, &this_face, &mut state.env(None)); + state.restore_attributes(&mut whole, &mut w)?; + copied + }; + let copied = match copied { + Ok((c, _)) => c, + Err(e) => return Ok(Err(status(e))), + }; + // The capi's `copied.GetSize() != 1`: `SMLIB_ERR_EMPTY`. + let [f] = copied.as_slice() else { return Ok(Err(Error::Empty)) }; + w.refresh_cached_topology(); + (w, Some(*f)) + }; + let Some(f) = f else { return Ok(Err(Error::NoMatch { point: 0 })) }; + let loops = face_p2::loops(&w, f, false, None)?; + if loops.is_empty() { + return Ok(Err(Error::Unsupported)); + } + let inner = loops.get(1..).unwrap_or_default(); + // The holes taken away: all of them, or the one nearest each point. + let mut removed: Vec = Vec::new(); + match holes { + None => removed.extend_from_slice(inner), + Some(points) => { + for p in points { + let p = p3(*p); + let (mut best, mut d0) = (None, f64::INFINITY); + for &l in inner { + let d = loop_distance(k, &w, l, p)?; + if d < d0 { + d0 = d; + best = Some(l); + } + } + let Some(best) = best.filter(|_| d0 <= tol) else { return Ok(Err(Error::NoMatch { point: 0 })) }; + if !removed.contains(&best) { + removed.push(best); + } + } + } + } + if !outer { + for &l in &removed { + // A hole whose edges another face of the body uses is no hole of a sheet. + let deleted = face_p1::delete_inner_loop_with_kernel_callbacks(k, &mut w, f, l); + if deleted.is_err() { + return Ok(Err(Error::Unsupported)); + } + } + w.refresh_cached_topology(); + return Ok(Ok(w)); + } + // The outer boundary: the surface's parameter box (a free-form surface's own, an + // analytic one's the span of the face). + let Some(surf) = face_p2::surface(&w, f)?.cloned() else { return Ok(Err(Error::Unsupported)) }; + let mut bx = surf.natural_uv_domain_with_nurb(k)?; + if !matches!(surf.kind(), SurfaceKind::BSpline(_)) { + let Some(outer_loop) = loops.first() else { return Ok(Err(Error::Unsupported)) }; + match span_of(k, &mut w, *outer_loop)? { + Some(s) => bx = s, + None => return Ok(Err(Error::Unsupported)), + } + } + let mut kept = Vec::new(); + for &l in inner { + if removed.contains(&l) { + continue; + } + match pieces_of(&w, l)? { + Some(p) => kept.push(p), + None => return Ok(Err(Error::Unsupported)), + } + } + let copy = surf.copy(k)?; + let was = upward(&w, f)?; + let mut r = if in_place { + None + } else { + let mut r = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + r.set_tolerance(w.tolerance()); + Some(r) + }; + let target: &mut Brep = match r.as_mut() { + Some(r) => r, + None => &mut w, + }; + let mut nf = match build_faces(target, |t, fenv| create_face_from_surface(k, t, Some(copy), &bx, fenv)) { + Ok(nf) => nf, + Err(e) => return Ok(Err(status(e))), + }; + target.refresh_cached_topology(); + let _ = target; + for hole in &kept { + let done = match r.as_mut() { + Some(r) => rebuild_hole(k, Some(&w), r, &mut nf, hole)?, + None => rebuild_hole(k, None, &mut w, &mut nf, hole)?, + }; + if let Err(e) = done { + return Ok(Err(e)); + } + } + match r.as_mut() { + Some(r) => copy_attributes(&w, EntityRef::Face(f), r, EntityRef::Face(nf))?, + None => copy_attributes_within(&mut w, EntityRef::Face(f), EntityRef::Face(nf))?, + } + // The face faces the way the old one did. + let target: &mut Brep = match r.as_mut() { + Some(r) => r, + None => &mut w, + }; + let now = upward(target, nf)?; + if let (Some(a), Some(b)) = (was, now) + && a != b + && let Err(e) = face_p1::flip_face_orientation(target, nf) + { + return Ok(Err(status(e))); + } + if let Some(r) = r { + return Ok(Ok(r)); + } + // The old face goes, with the edges and vertices only it used: an edit of the brep, + // which SMLib allows only in edit mode. + let editing = w.editing_enabled(); + w.set_editing_enabled(true); + let deleted = with_kernel_callbacks(k, &mut w, |k, w, env| { + let mut marks = Marks::default(); + let options = DeleteFaceOptions { delete_edges_vertices: true, ..DeleteFaceOptions::default() }; + delete_face(k, w, env, &mut marks, f, options) + }); + w.set_editing_enabled(editing); + w.refresh_cached_topology(); + if let Err(e) = deleted { + return Ok(Err(status(e))); + } + Ok(Ok(w)) + })? + .map(Body::new) +} + +/// One side of a face's parameter box (`Side`): which, whether it is a u = const line (the face +/// grows in u), and whether it is at the box's max. +#[derive(Clone, Copy)] +struct Side { + which: SurfParamType, + u: bool, + high: bool, +} + +/// `SIDES`. +const SIDES: [Side; 4] = [ + Side { which: SurfParamType::UMin, u: true, high: false }, + Side { which: SurfParamType::UMax, u: true, high: true }, + Side { which: SurfParamType::VMin, u: false, high: false }, + Side { which: SurfParamType::VMax, u: false, high: true }, +]; + +/// `SAMPLES`. +const SAMPLES: usize = 9; + +/// `on_side`: the point of side `s` of `dom` at `t` in [0, 1] along it, in UV. +fn on_side(dom: &Extent2d, s: Side, t: f64) -> Point2 { + let (lo, hi) = (dom.min(), dom.max()); + if s.u { + Point2::new(if s.high { hi.x } else { lo.x }, lo.y + t * (hi.y - lo.y)) + } else { + Point2::new(lo.x + t * (hi.x - lo.x), if s.high { hi.y } else { lo.y }) + } +} + +/// `eval`: the point at `uv` with its u and v derivatives; `None` where it does not evaluate or +/// is not finite. +fn eval(k: &Kernel, surf: &Surface, uv: Point2) -> Option<(Vec3, Vec3, Vec3)> { + let d = surf.evaluate(k, uv, 1, 1, EvaluateFlags::default()).ok()?; + let p = *d.first()?.first()?; + let du = *d.get(1)?.first()?; + let dv = *d.first()?.get(1)?; + (p.length().is_finite() && du.length().is_finite() && dv.length().is_finite()).then_some((p, du, dv)) +} + +fn point_at(k: &Kernel, surf: &Surface, uv: Point2) -> Option { + eval(k, surf, uv).map(|(p, _, _)| p) +} + +/// `edge_distance`. +fn edge_distance(k: &Kernel, b: &Brep, e: EdgeId, p: Vec3) -> f64 { + match edge_closest_point(k, b, e, p) { + Ok(c) if c.closest.distance.is_finite() => c.closest.distance, + _ => f64::INFINITY, + } +} + +/// `nearest_edge`: the distance to the nearest of `edges`, with that edge. +fn nearest_edge(k: &Kernel, b: &Brep, edges: &[EdgeId], p: Vec3) -> (f64, Option) { + let (mut best, mut which) = (f64::INFINITY, None); + for &e in edges { + let d = edge_distance(k, b, e, p); + if d < best { + best = d; + which = Some(e); + } + } + (best, which) +} + +/// `is_boundary`: whether side `s` of the face's box is a side of the face (its samples on the +/// face's edges), and whether it collapses to a point (a pole). +fn is_boundary(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edges: &[EdgeId], tol: f64) -> (bool, bool) { + let (Some(a), Some(z), Some(m)) = + (point_at(k, surf, on_side(dom, s, 0.0)), point_at(k, surf, on_side(dom, s, 1.0)), point_at(k, surf, on_side(dom, s, 0.5))) + else { + return (false, false); + }; + if (a - z).length() <= tol && (a - m).length() <= tol { + return (true, true); + } + let all = (0..=SAMPLES).all(|i| match point_at(k, surf, on_side(dom, s, i as f64 / SAMPLES as f64)) { + Some(p) => nearest_edge(k, b, edges, p).0 <= tol, + None => false, + }); + (all, false) +} + +/// `side_edge`: the edge of `edges` side `s` of `dom` runs along (nearest the side's middle), or +/// `None` for a side collapsed to a point. +fn side_edge(k: &Kernel, b: &Brep, surf: &Surface, dom: &Extent2d, s: Side, edges: &[EdgeId], on: f64) -> Option { + let a = point_at(k, surf, on_side(dom, s, 0.0))?; + let z = point_at(k, surf, on_side(dom, s, 1.0))?; + let m = point_at(k, surf, on_side(dom, s, 0.5))?; + if (a - z).length() <= on && (a - m).length() <= on { + return None; + } + let (d, e) = nearest_edge(k, b, edges, m); + e.filter(|_| d <= on) +} + +/// `across`: how far `ext` (the face's surface extended past side `s` of its box `dom`, on the +/// same parameters) runs past that side, measured across it through the side's middle, by +/// 16-point Gauss-Legendre quadrature on 64 pieces. Negative when it can't be evaluated. +fn across(k: &Kernel, ext: &Surface, dom: &Extent2d, s: Side) -> f64 { + const X: [f64; 8] = [ + 0.0950125098376374, + 0.2816035507792589, + 0.4580167776572274, + 0.6178762444026438, + 0.755404408355003, + 0.8656312023878318, + 0.9445750230732326, + 0.9894009349916499, + ]; + const W: [f64; 8] = [ + 0.1894506104550685, + 0.1826034150449236, + 0.1691565193950025, + 0.1495959888165767, + 0.1246289712555339, + 0.0951585116824928, + 0.0622535239386479, + 0.0271524594117541, + ]; + const PIECES: usize = 64; + let Ok(bx) = ext.natural_uv_domain_with_nurb(k) else { return -1.0 }; + let mid = on_side(dom, s, 0.5); + let from = if s.u { mid.x } else { mid.y }; + let to = match (s.u, s.high) { + (true, true) => bx.max().x, + (true, false) => bx.min().x, + (false, true) => bx.max().y, + (false, false) => bx.min().y, + }; + let h = (to - from) / PIECES as f64; + let mut length = 0.0; + for piece in 0..PIECES { + let c = from + (piece as f64 + 0.5) * h; + for i in 0..16 { + let (x, w) = if i < 8 { (-X[i], W[i]) } else { (X[i - 8], W[i - 8]) }; + let t = c + 0.5 * h * x; + let uv = if s.u { Point2::new(t, mid.y) } else { Point2::new(mid.x, t) }; + let Some((_, du, dv)) = eval(k, ext, uv) else { return -1.0 }; + length += 0.5 * h.abs() * w * if s.u { du } else { dv }.length(); + } + } + length +} + +/// `cone_continued`: a cone's or cylinder's face continued naturally `distance` past side `s` of +/// its box `dom`, as the analytic itself over a wider STEP domain (degrees round the axis, +/// height along it). `None` where the cone can't be widened so (more than a turn, or past the +/// apex). +fn cone_continued(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, distance: f64) -> Option { + let SurfaceKind::Cone(cone) = surf.kind() else { return None }; + let mut cone: Cone = cone.as_ref().clone(); + let step = |uv: Point2| cone.convert_uv_from_nurbs_to_step(k, uv).ok(); + let (a, z, m) = (step(on_side(dom, s, 0.0))?, step(on_side(dom, s, 1.0))?, step(on_side(dom, s, 0.5))?); + let (lo, hi) = (step(dom.min())?, step(dom.max())?); + let semi = cone.canonical().semi_angle_deg; + let mut bx = [[lo.x.min(hi.x), lo.x.max(hi.x)], [lo.y.min(hi.y), lo.y.max(hi.y)]]; + let eps = 1e-9 * (1.0 + m.x.abs() + m.y.abs()); + // The STEP coordinate constant along the side is the one the face grows in. + let c = if (a.x - z.x).abs() <= eps { + 0 + } else if (a.y - z.y).abs() <= eps { + 1 + } else { + return None; + }; + let at = if c == 0 { m.x } else { m.y }; + let row = bx.get_mut(c)?; + let end = if (at - row[1]).abs() <= (at - row[0]).abs() { 1 } else { 0 }; + let by = if c == 0 { + let r = cone.radius(m.y); + if !(r > 0.0) { + return None; + } + (distance / r).to_degrees() + } else { + distance * semi.to_radians().cos() + }; + if end == 1 { + row[1] += by; + } else { + row[0] -= by; + } + if bx[0][1] - bx[0][0] > 360.0 + 1e-9 { + return None; + } + let wider = Extent2d::from_bounds(bx[0][0], bx[1][0], bx[0][1], bx[1][1]); + cone.adjust_step_uv_domain(k, &wider).ok()?; + Some(Surface::from(cone)) +} + +/// `same_patch`: whether `p` and `q` span the same patch: their boxes' corners the same points, +/// in any order, within `tol`. +fn same_patch(k: &Kernel, p: &Surface, q: &Surface, tol: f64) -> bool { + let (Ok(bp), Ok(bq)) = (p.natural_uv_domain_with_nurb(k), q.natural_uv_domain_with_nurb(k)) else { return false }; + let corners = |b: &Extent2d| { + let (lo, hi) = (b.min(), b.max()); + [lo, Point2::new(hi.x, lo.y), hi, Point2::new(lo.x, hi.y)] + }; + corners(&bp).into_iter().all(|u| { + let Some(x) = point_at(k, p, u) else { return false }; + corners(&bq).into_iter().any(|v| point_at(k, q, v).is_some_and(|y| (x - y).length() <= tol)) + }) +} + +/// `extended_by`: the surface `surf` (whose face is its whole box `dom`) extended past side `s` as +/// `ct` says, so that it runs `distance` past that side measured across it ([`across`]), within +/// 1e-9 of it. `CreateExtendedSurface` takes its distance as an estimate, and how far it then runs +/// is increasing in what it is asked: a bracketed solve on that (each ask scaled by the distance +/// over what it gave until two asks fall either side, then Illinois false position). An extension +/// that moves the face's side off its parameters, or a target not met, is [`Error::NotExact`], +/// never the surface as it came out. +fn extended_by(k: &Kernel, surf: &Surface, dom: &Extent2d, s: Side, ct: ContinuityType, distance: f64, model_tol: f64) -> Result { + const BRACKET_TRIES: usize = 40; + const SOLVE_TRIES: usize = 100; + let tol = 1e-9 * distance; + // How far asking `ask` runs, and the surface it makes. + let try_ask = |ask: f64| -> Result<(Surface, f64)> { + let tried = surf.create_extended_surface_dir(k, s.which, ask, ct).map_err(status)?; + // The face's side stays where it was on the extended surface's parameters. + let mid = on_side(dom, s, 0.5); + let stays = matches!((point_at(k, surf, mid), point_at(k, &tried, mid)), (Some(was), Some(now)) if (was - now).length() <= model_tol); + if !stays { + return Err(Error::NotExact); + } + let got = across(k, &tried, dom, s); + if got.is_finite() && got > 0.0 { Ok((tried, got)) } else { Err(Error::NotExact) } + }; + // Asks that run short of and past `distance`, by f = got - distance. + let (mut short, mut past) = (None, None); + let mut ask = distance; + for _ in 0..BRACKET_TRIES { + let (made, got) = try_ask(ask)?; + let f = got - distance; + if f.abs() <= tol { + return Ok(made); + } + if f < 0.0 { + short = Some((ask, f)); + } else { + past = Some((ask, f)); + } + if short.is_some() && past.is_some() { + break; + } + ask *= distance / got; + } + let (Some((mut lo, mut flo)), Some((mut hi, mut fhi))) = (short, past) else { return Err(Error::NotExact) }; + // Illinois: false position, halving the value at the end that stays when the same end moves + // twice running. + #[derive(PartialEq)] + enum Moved { + Neither, + Lo, + Hi, + } + let mut moved = Moved::Neither; + for _ in 0..SOLVE_TRIES { + let x = hi - fhi * (hi - lo) / (fhi - flo); + if !(x > lo.min(hi) && x < lo.max(hi)) { + break; + } + let (made, got) = try_ask(x)?; + let f = got - distance; + if f.abs() <= tol { + return Ok(made); + } + if f < 0.0 { + (lo, flo) = (x, f); + if moved == Moved::Lo { + fhi *= 0.5; + } + moved = Moved::Lo; + } else { + (hi, fhi) = (x, f); + if moved == Moved::Hi { + flo *= 0.5; + } + moved = Moved::Hi; + } + } + Err(Error::NotExact) +} + +/// `smlib_extend_sheet`: the one-face sheet `sheet`, whose face is its surface's whole parameter +/// box, continued `distance` past the side its edge nearest `point` (within `tol`) runs along, +/// as `how` says. A sheet of another shape is [`Error::Unsupported`]; an extension that cannot be +/// made `distance` long across the side (within 1e-9 of it) is [`Error::NotExact`]. +pub(crate) fn extend_sheet(sheet: &Body, point: [f64; 3], tol: f64, distance: f64, how: Continuation) -> Result { + if !point.iter().all(|x| x.is_finite()) || !tol.is_finite() || !(tol > 0.0) || !distance.is_finite() || !(distance > 0.0) { + return Err(Error::Argument); + } + if sheet.0.is_poisoned() { + return Err(Error::Internal("smlib_extend_sheet: the body was left unfinished by an earlier panic".to_owned())); + } + run("smlib_extend_sheet", |k| { + let b = { + let g = sheet.0.lock().unwrap_or_else(PoisonError::into_inner); + super::dump::input(k, "sheet", &g); + brep_copy(k, &g)? + }; + let faces = brep_p1::faces(&b, None)?; + let [f] = faces.as_slice() else { return Ok(Err(Error::Unsupported)) }; + let f = *f; + let Some(surf) = face_p2::surface(&b, f)?.cloned() else { return Ok(Err(Error::Unsupported)) }; + if face_p2::loops(&b, f, false, None)?.len() != 1 { + return Ok(Err(Error::Unsupported)); + } + let edges = face_p4::edges(&b, f, None, false, None)?; + let (d, picked) = nearest_edge(k, &b, &edges, p3(point)); + let Some(picked) = picked.filter(|_| d <= tol) else { return Ok(Err(Error::NoMatch { point: 0 })) }; + let model_tol = b.tolerance().max(1e-9); + let on = 10.0 * model_tol; + let dom = face_p2::uv_domain(&b, f)?; + // Every side of the box is a side of the face (the face is its whole box), and the picked + // edge is all of one side. + let mut grow = None; + for s in SIDES { + let (boundary, collapsed) = is_boundary(k, &b, &surf, &dom, s, &edges, on); + if !boundary { + return Ok(Err(Error::Unsupported)); + } + if collapsed { + continue; + } + let all = (0..=SAMPLES).all(|i| match point_at(k, &surf, on_side(&dom, s, i as f64 / SAMPLES as f64)) { + Some(p) => edge_distance(k, &b, picked, p) <= on, + None => false, + }); + if all { + grow = Some(s); + } + } + let Some(grow) = grow else { return Ok(Err(Error::Unsupported)) }; + // SMLib extends the whole surface; the face must be all of it. + let natural = surf.natural_uv_domain_with_nurb(k)?; + let (nl, nh, dl, dh) = (natural.min(), natural.max(), dom.min(), dom.max()); + let ptol = 1e-9 * 1f64.max((nh.x - nl.x).max(nh.y - nl.y)); + if (nl.x - dl.x).abs() > ptol || (nl.y - dl.y).abs() > ptol || (nh.x - dh.x).abs() > ptol || (nh.y - dh.y).abs() > ptol { + return Ok(Err(Error::Unsupported)); + } + let ct = match how { + Continuation::Tangent => ContinuityType::G1, + Continuation::Curvature => ContinuityType::G1G2, + Continuation::Natural => ContinuityType::CInfinity, + }; + // CreateExtendedSurface takes its distance as an estimate (a natural parabola falls 2.5% + // short, a G2 run-off of a circle runs more than twice as far): the distance asked of it + // is solved for until the extension measures `distance` across the side's middle. + let extended = match extended_by(k, &surf, &dom, grow, ct, distance, model_tol) { + Ok(e) => e, + Err(e) => return Ok(Err(e)), + }; + // A cone or cylinder continued naturally stays one, as the analytic widened, else SMLib + // gives it back its analytic kind where it can. + let grown = match how { + Continuation::Natural => cone_continued(k, &surf, &dom, grow, distance).filter(|g| same_patch(k, g, &extended, 10.0 * model_tol)), + _ => None, + }; + let grown = match grown { + Some(g) => g, + None => match extended.copy_and_add_analytics(k) { + Ok(g) => g, + Err(e) => return Ok(Err(status(e))), + }, + }; + let bx = grown.natural_uv_domain_with_nurb(k)?; + let mut r = new_brep(k, USE_DEFAULT, USE_DEFAULT)?; + r.set_tolerance(b.tolerance()); + let grown_eval = grown.copy(k)?; + let nf = match build_faces(&mut r, |t, fenv| create_face_from_surface(k, t, Some(grown), &bx, fenv)) { + Ok(nf) => nf, + Err(e) => return Ok(Err(status(e))), + }; + r.refresh_cached_topology(); + // The face and each side stand for the face and side they continue. + copy_attributes(&b, EntityRef::Face(f), &mut r, EntityRef::Face(nf))?; + let made = face_p4::edges(&r, nf, None, false, None)?; + let new_on = 10.0 * r.tolerance().max(1e-9); + for s in SIDES { + let from = side_edge(k, &b, &surf, &dom, s, &edges, on); + let to = side_edge(k, &r, &grown_eval, &bx, s, &made, new_on); + if let (Some(from), Some(to)) = (from, to) { + copy_attributes(&b, EntityRef::Edge(from), &mut r, EntityRef::Edge(to))?; + } + } + // The new face faces the way the old one did. + if let (Some(was), Some(now)) = (upward(&b, f)?, upward(&r, nf)?) + && was != now + && let Err(e) = face_p1::flip_face_orientation(&mut r, nf) + { + return Ok(Err(status(e))); + } + Ok(Ok(r)) + })? + .map(Body::new) +} + +/// A point inside each face, in the body's face order (`smlib_brep_face_points`: +/// `SmFace::CalculateAnInternalPoint`). +pub(crate) fn face_points(body: &Body) -> Result> { + super::with_body("smlib_brep_face_points", body, |k, b| { + let faces = b.face_list().to_vec(); + faces.into_iter().map(|f| face_internal_point(k, b, f).map(|p| [p.x, p.y, p.z])).collect() + }) +} diff --git a/crates/smlib/src/core/trim_tests.rs b/crates/smlib/src/core/trim_tests.rs new file mode 100644 index 0000000000..54f1077ba2 --- /dev/null +++ b/crates/smlib/src/core/trim_tests.rs @@ -0,0 +1,429 @@ +//! Untrim and extend on solidcore, against closed forms: areas and counts, and every result exports to a +//! record that validates. With SMLib built, the same input converted to the C++ kernel gives the +//! same result or the same error (parity only: the expected values are the analytic ones). +//! +//! An untrim's body is a 20 x 20 x 5 plate with a 3 mm through hole at (10, 10), made on +//! solidcore; its top face is a square with a round hole. An extend's is a one-face sheet that is +//! its surface's whole parameter box: a 10 mm square, or a quarter of a cylinder of radius 10 and +//! height 10. + +use std::f64::consts::{FRAC_1_SQRT_2, PI}; + +use super::trim::{extend_sheet, untrim_face}; +use super::{Body, area, counts, make_box, make_cylinder, to_arrays}; +use crate::export::native; +use crate::trim::Continuation; +use crate::{BooleanOp, Error}; + +const TOL: f64 = 0.01; +/// A point on the top face, away from the hole. +const TOP: [f64; 3] = [2.0, 2.0, 5.0]; +/// A point on the hole's rim on the top face. +const RIM: [f64; 3] = [13.0, 10.0, 5.0]; + +/// The plate's top as a sheet of its own: a 20 mm square at z = 0 with a 3 mm hole at (10, 10). +fn holed_sheet() -> Body { + use crate::sweeps::Curve; + let line = |a: [f64; 3], b: [f64; 3]| Curve::Line { start: a, end: b }; + let (a, b, c, d) = ([0.0, 0.0, 0.0], [20.0, 0.0, 0.0], [20.0, 20.0, 0.0], [0.0, 20.0, 0.0]); + let hole = Curve::Arc { center: [10.0, 10.0, 0.0], axis: [0.0, 0.0, 1.0], reference: [1.0, 0.0, 0.0], radius: 3.0, start: 0.0, end: 2.0 * PI }; + super::sweep::planar_faces(&[line(a, b), line(b, c), line(c, d), line(d, a), hole]).unwrap() +} + +/// On the sheet: a point away from the hole, and one on the hole's rim. +const SHEET: [f64; 3] = [2.0, 2.0, 0.0]; +const SHEET_RIM: [f64; 3] = [13.0, 10.0, 0.0]; + +#[derive(Clone, Copy, Debug)] +enum Input { + Plate, + Sheet, +} + +impl Input { + fn core(self) -> Body { + match self { + Input::Plate => plate(), + Input::Sheet => holed_sheet(), + } + } +} + +fn plate() -> Body { + let block = make_box([0.0; 3], [20.0, 20.0, 5.0]).unwrap(); + let pin = make_cylinder([10.0, 10.0, -1.0], 3.0, 7.0).unwrap(); + super::boolean::boolean(&block, &pin, BooleanOp::Subtract).unwrap().unwrap() +} + +#[derive(Clone, Copy, Debug)] +struct Case { + input: Input, + point: [f64; 3], + /// `None`: every hole. + holes: Option<&'static [[f64; 3]]>, + outer: bool, + in_place: bool, +} + +#[derive(Clone, Debug, PartialEq)] +enum Expect { + /// A sheet: its area and face count. + Sheet(f64, usize), + Fails(Error), +} + +fn case(name: &str) -> (Case, Expect) { + let c = |input, point, holes, outer, in_place| Case { input, point, holes, outer, in_place }; + let square = 400.0; + let holed = 400.0 - 9.0 * PI; + let (plate, sheet) = (Input::Plate, Input::Sheet); + match name { + // The plate's top face, alone. + "every_hole_taken_away" => (c(plate, TOP, None, false, false), Expect::Sheet(square, 1)), + "the_hole_at_a_point_taken_away" => (c(plate, TOP, Some(&[RIM]), false, false), Expect::Sheet(square, 1)), + // The outer boundary back on the plane's box of the face's span, the hole kept. + "outer_boundary_back_hole_kept" => (c(plate, TOP, Some(&[]), true, false), Expect::Sheet(holed, 1)), + "outer_boundary_back_holes_gone" => (c(plate, TOP, None, true, false), Expect::Sheet(square, 1)), + // In the solid, the hole's edges are the bore's too: no hole of a sheet. + "a_hole_of_a_solid_in_place" => (c(plate, TOP, None, false, true), Expect::Fails(Error::Unsupported)), + "a_point_near_no_face" => (c(plate, [50.0, 50.0, 50.0], None, false, false), Expect::Fails(Error::NoMatch { point: 0 })), + // The holed sheet, in place. + "sheet_every_hole_taken_away" => (c(sheet, SHEET, None, false, true), Expect::Sheet(square, 1)), + "sheet_the_hole_at_a_point_taken_away" => (c(sheet, SHEET, Some(&[SHEET_RIM]), false, true), Expect::Sheet(square, 1)), + "sheet_outer_boundary_back_hole_kept" => (c(sheet, SHEET, Some(&[]), true, true), Expect::Sheet(holed, 1)), + "sheet_outer_boundary_back_holes_gone" => (c(sheet, SHEET, None, true, true), Expect::Sheet(square, 1)), + "sheet_a_point_near_no_hole" => (c(sheet, SHEET, Some(&[SHEET]), false, true), Expect::Fails(Error::NoMatch { point: 0 })), + _ => panic!("no case {name}"), + } +} + +#[derive(Debug, PartialEq)] +struct Measured { + area: f64, + counts: [usize; 3], +} + +type Got = std::result::Result; + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs() +} + +fn judge(name: &str, who: &str, got: Got) -> Got { + let (_, expect) = case(name); + match (&expect, &got) { + (Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"), + (Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"), + (_, Err(g)) => panic!("{name}: {who} failed: {g}"), + (Expect::Sheet(a, faces), Ok(m)) => { + assert!(rel(m.area, *a) < 1e-9, "{name}: {who} area {} vs analytic {a}", m.area); + assert_eq!(m.counts[0], *faces, "{name}: {who} faces"); + } + } + got +} + +fn solidcore_case(name: &str) -> Got { + let (c, _) = case(name); + let got = untrim_face(&c.input.core(), c.point, TOL, c.holes, c.outer, c.in_place).map(|b| { + let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); + Measured { area: area(&b, 1e-8).unwrap(), counts: counts(&b).unwrap() } + }); + judge(name, "solidcore", got) +} + +/// The case on the C++ kernel, called directly (whatever the choice), on the plate converted +/// from solidcore's. +#[cfg(feature = "native")] +fn cpp_case(name: &str) -> Got { + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let (c, _) = case(name); + let body = Brep::made_core(c.input.core()); + let r = ffi::trim::untrim_face(body.cpp().unwrap(), &c.point, TOL, c.holes, c.outer, c.in_place).map_err(|s| match s { + ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 }, + s => error(s), + }); + let got = r.map(|h| { + let a = ffi::measure::area(&h, 1e-8).unwrap(); + let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap()); + let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + Measured { area: a, counts: n } + }); + judge(name, "C++", got) +} + +#[cfg(feature = "native")] +fn parity(name: &str) { + match (solidcore_case(name), cpp_case(name)) { + (Ok(a), Ok(b)) => { + assert!(rel(a.area, b.area) < 1e-9, "{name}: solidcore {} vs C++ {}", a.area, b.area); + assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)"); + } + (a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"), + } +} + +macro_rules! cases { + ($($m:ident $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::solidcore_case(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::cpp_case(stringify!($m)); + } + } + )*}; +} + +cases!( + every_hole_taken_away, + the_hole_at_a_point_taken_away, + outer_boundary_back_hole_kept, + outer_boundary_back_holes_gone, + a_hole_of_a_solid_in_place, + a_point_near_no_face, + sheet_every_hole_taken_away, + sheet_the_hole_at_a_point_taken_away, + sheet_outer_boundary_back_hole_kept, + sheet_outer_boundary_back_holes_gone, + sheet_a_point_near_no_hole, +); + +/// The capi's argument checks, before any kernel call. +#[test] +fn hostile_arguments_are_refused() { + let b = make_box([0.0; 3], [10.0; 3]).unwrap(); + let p = [5.0, 5.0, 10.0]; + assert_eq!(untrim_face(&b, [f64::NAN, 0.0, 0.0], TOL, None, true, false).err(), Some(Error::Argument)); + for t in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!(untrim_face(&b, p, t, None, true, false).err(), Some(Error::Argument), "tolerance {t}"); + } + assert_eq!(untrim_face(&b, p, TOL, Some(&[]), false, false).err(), Some(Error::Argument), "nothing to do"); + assert_eq!(untrim_face(&b, p, TOL, Some(&[[f64::INFINITY; 3]]), false, false).err(), Some(Error::Argument)); + let many = vec![p; 10_001]; + assert_eq!(untrim_face(&b, p, TOL, Some(&many), false, false).err(), Some(Error::Argument)); +} + +/// The body untrimmed is never changed. +#[test] +fn the_body_stays_as_it_was() { + let b = plate(); + let was = counts(&b).unwrap(); + let _ = untrim_face(&b, TOP, TOL, None, true, false); + let _ = untrim_face(&b, TOP, TOL, None, false, true); + assert_eq!(counts(&b).unwrap(), was); +} + +/// Face and edge tags (`Brep::tagged`) through an untrim on solidcore, as on C++. +#[cfg(feature = "native")] +mod history { + use crate::{Brep, ffi}; + + #[test] + fn tags_cross_an_untrim() { + let _cpp = crate::route::on_cpp(); + crate::init().unwrap(); + let a = Brep::made_core(super::holed_sheet()).tagged(0).unwrap(); + for (holes, outer) in [(None, false), (Some(&[][..]), true)] { + let cpp = ffi::trim::untrim_face(a.cpp().unwrap(), &super::SHEET, super::TOL, holes, outer, true).unwrap(); + let cpp = Brep::made(cpp).history().unwrap(); + let core = super::untrim_face(a.core().unwrap(), super::SHEET, super::TOL, holes, outer, true).unwrap(); + let core = Brep::made_core(core).history().unwrap(); + assert_eq!(core.canonical(), cpp.canonical(), "holes {holes:?}, outer {outer}"); + } + } +} + +/// A one-face sheet on `surface` over `domain`, the face its whole box. +fn whole_face(surface: solidcore::surface::surface::Surface) -> Body { + use solidcore::topology::brep::p3::new_brep; + use solidcore::topology::brep::p8::create_face_from_surface; + super::run("sheet", |k| { + let mut b = new_brep(k, solidcore::geom::tol::USE_DEFAULT, solidcore::geom::tol::USE_DEFAULT)?; + let domain = surface.natural_uv_domain_with_nurb(k)?; + super::sweep::build_faces(&mut b, |b, fenv| create_face_from_surface(k, b, Some(surface), &domain, fenv))?; + b.refresh_cached_topology(); + Ok(b) + }) + .map(Body::new) + .unwrap() +} + +#[derive(Clone, Copy, Debug)] +enum Sheet { + /// [0, 10]^2 at z = 0. + Square, + /// Radius 10 about +Z, from 0 to 90 degrees, z from 0 to 10. + QuarterCylinder, +} + +impl Sheet { + fn core(self) -> Body { + use solidcore::geom::extent2d::Extent2d; + use solidcore::geom::vector2d::Vec2; + use solidcore::geom::vector3d::Vec3; + use solidcore::surface::cone::p1::Cone; + use solidcore::surface::plane::p1::Plane; + use solidcore::surface::surface::Surface; + let (o, x, y) = (Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 0.0, 0.0), Vec3::new(0.0, 1.0, 0.0)); + match self { + Sheet::Square => whole_face(Surface::from(Plane::new(o, x, y, Vec2::new(1.0, 1.0), &Extent2d::from_bounds(0.0, 0.0, 10.0, 10.0)))), + Sheet::QuarterCylinder => whole_face(Surface::from(Cone::new_cylinder_bounded(o, x, y, 10.0, 0.0, 90.0, 10.0, false, false, false))), + } + } +} + +fn extend_case(name: &str) -> (Sheet, [f64; 3], f64, Continuation, Expect) { + let quarter = 5.0 * PI; + match name { + "extend_a_square" => (Sheet::Square, [10.0, 5.0, 0.0], 5.0, Continuation::Natural, Expect::Sheet(150.0, 1)), + "extend_a_square_tangent" => (Sheet::Square, [5.0, 10.0, 0.0], 5.0, Continuation::Tangent, Expect::Sheet(150.0, 1)), + "extend_a_square_curvature" => (Sheet::Square, [0.0, 5.0, 0.0], 2.0, Continuation::Curvature, Expect::Sheet(120.0, 1)), + "extend_a_cylinder_along_its_axis" => { + (Sheet::QuarterCylinder, [FRAC_1_SQRT_2 * 10.0, FRAC_1_SQRT_2 * 10.0, 10.0], 5.0, Continuation::Natural, Expect::Sheet(quarter * 15.0, 1)) + } + "extend_a_cylinder_round_its_axis" => { + (Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Natural, Expect::Sheet(10.0 * (quarter + 5.0), 1)) + } + // Run-offs 5 long across the surface whatever their curvature: the strip stays its section + // swept 10 along z. A circle's G2 run-off runs about 2.3 times what it is asked and more + // than doubles with it (fork 0f7b209), so the ask must be solved for. + "extend_a_cylinder_tangent_run_off" => { + (Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Tangent, Expect::Sheet(10.0 * (quarter + 5.0), 1)) + } + "extend_a_cylinder_curvature_run_off" => { + (Sheet::QuarterCylinder, [10.0, 0.0, 5.0], 5.0, Continuation::Curvature, Expect::Sheet(10.0 * (quarter + 5.0), 1)) + } + "extend_picks_near_no_edge" => (Sheet::Square, [50.0, 50.0, 0.0], 5.0, Continuation::Natural, Expect::Fails(Error::NoMatch { point: 0 })), + _ => panic!("no case {name}"), + } +} + +fn extend_judge(name: &str, who: &str, got: Got) -> Got { + let (.., expect) = extend_case(name); + match (&expect, &got) { + (Expect::Fails(e), Err(g)) => assert_eq!(g, e, "{name}: {who} failed otherwise"), + (Expect::Fails(e), Ok(m)) => panic!("{name}: {who} gave {m:?}, expected {e:?}"), + (_, Err(g)) => panic!("{name}: {who} failed: {g}"), + (Expect::Sheet(a, faces), Ok(m)) => { + assert!(rel(m.area, *a) < 1e-6, "{name}: {who} area {} vs analytic {a}", m.area); + assert_eq!(m.counts[0], *faces, "{name}: {who} faces"); + } + } + got +} + +fn extend_solidcore(name: &str) -> Got { + let (sheet, point, distance, how, _) = extend_case(name); + let got = extend_sheet(&sheet.core(), point, TOL, distance, how).map(|b| { + let (rec, _) = native(&to_arrays(&b).unwrap()).unwrap_or_else(|e| panic!("{name}: record: {e:?}")); + rec.validate().unwrap_or_else(|e| panic!("{name}: record: {e}")); + Measured { area: area(&b, 1e-8).unwrap(), counts: counts(&b).unwrap() } + }); + extend_judge(name, "solidcore", got) +} + +#[cfg(feature = "native")] +fn extend_cpp(name: &str) -> Got { + use crate::{Brep, error, ffi}; + crate::init().unwrap(); + let (sheet, point, distance, how, _) = extend_case(name); + let mode = match how { + Continuation::Natural => ffi::trim::EXTEND_NATURAL, + Continuation::Tangent => ffi::trim::EXTEND_TANGENT, + Continuation::Curvature => ffi::trim::EXTEND_CURVATURE, + }; + let body = Brep::made_core(sheet.core()); + let r = ffi::trim::extend_sheet(body.cpp().unwrap(), &point, TOL, distance, mode).map_err(|s| match s { + ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 }, + s => error(s), + }); + let got = r.map(|h| { + let a = ffi::measure::area(&h, 1e-8).unwrap(); + let n = ffi::counts(&h).unwrap().map(|x| usize::try_from(x).unwrap()); + let (rec, _) = native(&Brep::made(h).cpp_arrays().unwrap()).unwrap(); + rec.validate().unwrap(); + Measured { area: a, counts: n } + }); + extend_judge(name, "C++", got) +} + +#[cfg(feature = "native")] +fn extend_parity(name: &str) { + match (extend_solidcore(name), extend_cpp(name)) { + (Ok(a), Ok(b)) => { + assert!(rel(a.area, b.area) < 1e-9, "{name}: solidcore {} vs C++ {}", a.area, b.area); + assert_eq!(a.counts, b.counts, "{name}: counts (solidcore vs C++)"); + } + (a, b) => assert_eq!(a.err(), b.err(), "{name}: errors (solidcore vs C++)"), + } +} + +macro_rules! extend_cases { + ($($m:ident $(: $ign:meta)?),* $(,)?) => {$( + mod $m { + #[test] + $(#[$ign])? + fn solidcore_matches_the_closed_form() { + let _ = super::extend_solidcore(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + $(#[$ign])? + fn solidcore_matches_the_cpp_kernel() { + let _cpp = crate::route::on_cpp(); + super::extend_parity(stringify!($m)); + } + #[cfg(feature = "native")] + #[test] + fn the_cpp_kernel_matches_the_closed_form() { + let _cpp = crate::route::on_cpp(); + let _ = super::extend_cpp(stringify!($m)); + } + } + )*}; +} + +extend_cases!( + extend_a_square, + extend_a_square_tangent, + extend_a_square_curvature, + extend_a_cylinder_along_its_axis, + extend_a_cylinder_round_its_axis, + extend_a_cylinder_tangent_run_off, + extend_a_cylinder_curvature_run_off, + extend_picks_near_no_edge, +); + +/// The capi's argument checks for an extend, before any kernel call, and a body of more than +/// one face. +#[test] +fn hostile_extend_arguments_are_refused() { + let s = Sheet::Square.core(); + let p = [10.0, 5.0, 0.0]; + assert_eq!(extend_sheet(&s, [f64::NAN; 3], TOL, 5.0, Continuation::Natural).err(), Some(Error::Argument)); + for t in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!(extend_sheet(&s, p, t, 5.0, Continuation::Natural).err(), Some(Error::Argument), "tolerance {t}"); + assert_eq!(extend_sheet(&s, p, TOL, t, Continuation::Natural).err(), Some(Error::Argument), "distance {t}"); + } + let cube = make_box([0.0; 3], [10.0; 3]).unwrap(); + assert_eq!(extend_sheet(&cube, p, TOL, 5.0, Continuation::Natural).err(), Some(Error::Unsupported)); + // A face that is not its surface's whole box: a square with a hole. + assert_eq!(extend_sheet(&holed_sheet(), [20.0, 5.0, 0.0], TOL, 5.0, Continuation::Natural).err(), Some(Error::Unsupported)); +} diff --git a/crates/smlib/src/export.rs b/crates/smlib/src/export.rs index 78910c80c8..bf085c6d47 100644 --- a/crates/smlib/src/export.rs +++ b/crates/smlib/src/export.rs @@ -50,37 +50,59 @@ use crate::{Brep, Result, error, ffi, init}; impl Brep { /// A cylinder about +Z: base centre, radius and height (mm, > 0). pub fn make_cylinder(origin: [f64; 3], radius: f64, height: f64) -> Result { + if crate::route::backend() == crate::route::Backend::Solidcore { + return crate::core::make_cylinder(origin, radius, height).map(Brep::made_core); + } init()?; - ffi::make_cylinder(&origin, radius, height).map(Brep).map_err(error) + ffi::make_cylinder(&origin, radius, height).map(Brep::made).map_err(error) } /// A sphere: centre and radius (mm, > 0). pub fn make_sphere(center: [f64; 3], radius: f64) -> Result { + if crate::route::backend() == crate::route::Backend::Solidcore { + return crate::core::make_sphere(center, radius).map(Brep::made_core); + } init()?; - ffi::make_sphere(¢er, radius).map(Brep).map_err(error) + ffi::make_sphere(¢er, radius).map(Brep::made).map_err(error) } /// A cone or frustum about +Z: base centre, base radius (> 0), top radius (≥ 0, not the base /// radius) and height (> 0), in mm. pub fn make_cone(origin: [f64; 3], base_radius: f64, top_radius: f64, height: f64) -> Result { + if crate::route::backend() == crate::route::Backend::Solidcore { + return crate::core::make_cone(origin, base_radius, top_radius, height).map(Brep::made_core); + } init()?; - ffi::make_cone(&origin, base_radius, top_radius, height).map(Brep).map_err(error) + ffi::make_cone(&origin, base_radius, top_radius, height).map(Brep::made).map_err(error) } /// A ring torus about +Z: centre, major radius > minor radius > 0 (mm). pub fn make_torus(center: [f64; 3], major_radius: f64, minor_radius: f64) -> Result { + if crate::route::backend() == crate::route::Backend::Solidcore { + return crate::core::make_torus(center, major_radius, minor_radius).map(Brep::made_core); + } init()?; - ffi::make_torus(¢er, major_radius, minor_radius).map(Brep).map_err(error) + ffi::make_torus(¢er, major_radius, minor_radius).map(Brep::made).map_err(error) } - /// The flat arrays of this body that the mapping reads ([`Brep::export`]), copied out. + /// The flat arrays of this body that the mapping reads, from the kernel in force + /// ([`crate::route::backend`]). pub fn arrays(&self) -> Result { + match crate::route::backend() { + crate::route::Backend::Smlib => self.cpp_arrays(), + crate::route::Backend::Solidcore => crate::core::to_arrays(self.core()?), + } + } + + /// The flat arrays of the C++ kernel's body ([`Brep::export`]), every one copied out: the + /// mapping reads some, solidcore's import ([`crate::core::from_arrays`]) all of them. + pub(crate) fn cpp_arrays(&self) -> Result { let f = self.export()?; let mut out = Arrays::default(); - for &id in I64_IDS { + for id in REGION_SHELL_COUNT..=FACE_DEFINITION_REAL_COUNT { out.ints.insert(id, f.get_i64(id)?); } - for &id in F64_IDS { + for id in TOLERANCE..=FACE_DEFINITION_REALS { out.reals.insert(id, f.get_f64(id)?); } Ok(out) @@ -150,92 +172,6 @@ fn unsupported(what: impl std::fmt::Display) -> ExportError { // ---- The arrays (ids and kinds from `crate::flat`, as in capi/include/smlib.h) ------------ -/// The int64 arrays this mapping reads. -const I64_IDS: &[i32] = &[ - REGION_SHELL_COUNT, - REGION_TYPE, - SHELL_FACEUSE_COUNT, - SHELL_WIRE_EDGE_COUNT, - SHELL_POINT_TYPE, - FACEUSE_FACE, - FACEUSE_ORIENTATION, - FACE_LOOP_COUNT, - FACE_SURFACE_TYPE, - LOOP_EDGEUSE_COUNT, - LOOP_VERTEX, - EDGEUSE_EDGE, - EDGEUSE_ORIENTATION, - EDGEUSE_NEXT_RADIAL, - EDGEUSE_RADIAL_ENTRY, - EDGEUSE_NURBS_VERTEX_COUNT, - EDGEUSE_NURBS_ORDER, - EDGE_CURVE_TYPE, - EDGE_VERTICES, - EDGE_NURBS_VERTEX_COUNT, - EDGE_NURBS_ORDER, - FACE_NURBS_U_VERTEX_COUNT, - FACE_NURBS_V_VERTEX_COUNT, - FACE_NURBS_U_ORDER, - FACE_NURBS_V_ORDER, - FACE_NURBS_FORM, - EDGE_NURBS_FORM, - FACE_DEFINITION_REAL_COUNT, -]; - -/// The double arrays this mapping reads. -const F64_IDS: &[i32] = &[ - TOLERANCE, - EXTENT, - FACE_RANGE, - EDGE_RANGE, - VERTEX_POSITION, - EDGE_NURBS_CVS, - EDGE_NURBS_KNOTS, - EDGE_NURBS_WEIGHTS, - EDGE_CIRCLE_CENTER, - EDGE_CIRCLE_AXIS, - EDGE_CIRCLE_REF_DIRECTION, - EDGE_CIRCLE_RADIUS, - EDGE_LINE_ORIGIN, - EDGE_LINE_DIRECTION, - EDGE_ELLIPSE_CENTER, - EDGE_ELLIPSE_AXIS, - EDGE_ELLIPSE_REF_DIRECTION, - EDGE_ELLIPSE_X_RADIUS, - EDGE_ELLIPSE_Y_RADIUS, - EDGEUSE_NURBS_CVS, - EDGEUSE_NURBS_KNOTS, - EDGEUSE_NURBS_WEIGHTS, - FACE_NURBS_CVS, - FACE_NURBS_U_KNOTS, - FACE_NURBS_V_KNOTS, - FACE_NURBS_WEIGHTS, - FACE_SPHERE_CENTER, - FACE_SPHERE_AXIS, - FACE_SPHERE_REF_DIRECTION, - FACE_SPHERE_RADIUS, - FACE_PLANE_ORIGIN, - FACE_PLANE_AXIS, - FACE_PLANE_REF_DIRECTION, - FACE_CYLINDER_ORIGIN, - FACE_CYLINDER_AXIS, - FACE_CYLINDER_REF_DIRECTION, - FACE_CYLINDER_RADIUS, - FACE_CONE_ORIGIN, - FACE_CONE_AXIS, - FACE_CONE_REF_DIRECTION, - FACE_CONE_RADIUS, - FACE_CONE_SEMI_ANGLE, - FACE_TORUS_ORIGIN, - FACE_TORUS_AXIS, - FACE_TORUS_REF_DIRECTION, - FACE_TORUS_MAJOR_RADIUS, - FACE_TORUS_MINOR_RADIUS, - EDGE_TOLERANCE, - VERTEX_TOLERANCE, - FACE_DEFINITION_REALS, -]; - // The kind values as the arrays hold them (int64); a local item shadows the glob import. const SURFACE_NURBS: i64 = flat::SURFACE_NURBS as i64; const SURFACE_PLANE: i64 = flat::SURFACE_PLANE as i64; @@ -384,8 +320,9 @@ fn surfaces(flat: View, n_faces: usize) -> Res Result { init()?; - ffi::flat::import(&self.0).map(Brep).map_err(error) + ffi::flat::import(&self.0).map(Brep::made).map_err(error) } } impl Brep { /// This body as flat arrays. pub fn export(&self) -> Result { - ffi::flat::export(&self.0).map(Flat).map_err(error) + ffi::flat::export(self.cpp()?).map(Flat).map_err(error) } /// Whether SMLib finds this body one closed, manifold solid. pub fn is_manifold_solid(&self) -> Result { - ffi::flat::is_manifold_solid(&self.0).map_err(error) + if crate::route::backend() == crate::route::Backend::Solidcore { + return crate::core::is_manifold_solid(self.core()?); + } + ffi::flat::is_manifold_solid(self.cpp()?).map_err(error) } } diff --git a/crates/smlib/src/history.rs b/crates/smlib/src/history.rs index e2eedc1640..bee815bfec 100644 --- a/crates/smlib/src/history.rs +++ b/crates/smlib/src/history.rs @@ -14,7 +14,7 @@ use std::collections::BTreeSet; use solidcraft_geom::brep::history::{Change, EdgeOrigin, EdgeProv, FaceOrigin, FaceProv, Failed, History, Side}; -use crate::{Brep, Error, Result, error, ffi}; +use crate::{Brep, Error, Result, core, error, ffi, route}; /// A result face as SMLib reports it: how it relates to its origins, and the (operand, face) /// pairs it came from, sorted. @@ -67,20 +67,62 @@ impl Brep { /// A copy whose face i and edge i are tagged as face i and edge i of operand `operand` (in /// SMLib's order, which the copy keeps). Run an operation on tagged copies, then read its /// result's [`Brep::history`]. + /// + /// The tags are made in the kernel the body is in (solidcore's when it has its body, + /// [`crate::core::history`]); converted for the other, the body is tagged there the same way. pub fn tagged(&self, operand: usize) -> Result { - let operand = i32::try_from(operand).map_err(|_| Error::Argument)?; - ffi::history::tag(&self.0, operand).map(Brep).map_err(error) + let mut t = match self.core.get() { + Some(Ok(b)) => Brep::made_core(core::history::tag(b, operand)?), + _ => { + let k = i32::try_from(operand).map_err(|_| Error::Argument)?; + Brep::made(ffi::history::tag(self.cpp()?, k).map_err(error)?) + } + }; + t.operand = Some(operand); + Ok(t) } /// The history of this body, made by an operation from tagged operands: each face's and - /// edge's origins among them. Faces and edges no tag reached have none. + /// edge's origins among them. Faces and edges no tag reached have none. Read in the kernel + /// that made the body: the tags never cross between them. pub fn history(&self) -> Result { - let v = ffi::history::read(&self.0).map_err(error)?; + if self.maker == route::Backend::Solidcore { + return core::history::read(self.core()?); + } + let v = ffi::history::read(self.cpp()?).map_err(error)?; Raw::decode(&v).ok_or(Error::Exception) } } impl Raw { + /// This history with its faces in a canonical order (by origins, then change) and each edge + /// naming its faces by that order, the edges sorted: two kernels' histories of one result + /// compare equal whatever order each lists its faces and edges in. + #[cfg(all(test, feature = "native"))] + pub(crate) fn canonical(&self) -> Raw { + let mut order: Vec = (0..self.faces.len()).collect(); + order.sort_by(|a, b| { + let (fa, fb) = (&self.faces[*a], &self.faces[*b]); + (&fa.origins, fa.change as u8).cmp(&(&fb.origins, fb.change as u8)) + }); + let mut at = vec![0; order.len()]; + for (new, old) in order.iter().enumerate() { + at[*old] = new; + } + let faces = order.iter().map(|i| self.faces[*i].clone()).collect(); + let mut edges: Vec = self + .edges + .iter() + .map(|e| { + let mut f: Vec = e.faces.iter().map(|i| at[*i]).collect(); + f.sort(); + RawEdge { faces: f, origins: e.origins.clone() } + }) + .collect(); + edges.sort_by(|a, b| (&a.origins, &a.faces).cmp(&(&b.origins, &b.faces))); + Raw { faces, edges } + } + /// Decodes `smlib_history_read`'s records; `None` where they don't read as its layout. fn decode(v: &[i64]) -> Option { let mut r = Reader(v.iter()); diff --git a/crates/smlib/src/import.rs b/crates/smlib/src/import.rs index efd0fe1d84..aa346229c9 100644 --- a/crates/smlib/src/import.rs +++ b/crates/smlib/src/import.rs @@ -666,15 +666,23 @@ mod analytic; mod tests; impl crate::Brep { + /// A body of the kernel in force ([`crate::route::backend`]), from flat arrays. + pub(crate) fn from_arrays(a: &Arrays) -> crate::Result { + match crate::route::backend() { + crate::route::Backend::Smlib => crate::flat::Flat::from_arrays(a)?.import(), + crate::route::Backend::Solidcore => crate::core::from_arrays(a).map(crate::Brep::made_core), + } + } + /// The SMLib B-rep of a schema 4 record. `trusted` as in [`arrays`]. pub fn from_record(brep: &b::Brep, trusted: bool) -> crate::Result { let a = arrays(brep, trusted).map_err(crate::Error::Record)?; - crate::flat::Flat::from_arrays(&a)?.import() + crate::Brep::from_arrays(&a) } /// [`crate::Brep::from_record`] through [`arrays_keeping_splines`]. pub fn from_record_keeping_splines(brep: &b::Brep, trusted: bool) -> crate::Result { let a = arrays_keeping_splines(brep, trusted).map_err(crate::Error::Record)?; - crate::flat::Flat::from_arrays(&a)?.import() + crate::Brep::from_arrays(&a) } } diff --git a/crates/smlib/src/lib.rs b/crates/smlib/src/lib.rs index 8a63fb7b8b..213ac9708c 100644 --- a/crates/smlib/src/lib.rs +++ b/crates/smlib/src/lib.rs @@ -6,16 +6,19 @@ //! the handles, turns status codes into [`Error`], and sends SMLib's one-line-per-call log to the //! `log` crate (target `smlib`). //! -//! Built without the `native` feature, the crate needs no C++ toolchain and every call returns -//! [`Error::NotBuilt`]. +//! The `native` feature (the apps' `smlib`, off by default) compiles and links the C++ kernel. +//! Without it the crate needs no C++ toolchain, no SMLib sources and no prebuilt library: solidcore +//! answers every operation, and asking for the C++ kernel (`SOLIDCRAFT_KERNEL=smlib`) makes every +//! call that would reach it return [`Error::NotBuilt`], never the other kernel's answer. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::ffi::CString; use std::path::Path; -use std::sync::Once; use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::{Once, OnceLock}; mod check; +mod core; pub mod export; mod ffi; pub mod flat; @@ -24,6 +27,7 @@ pub mod import; mod measure; mod ops; pub mod pcurves; +pub mod route; mod sheets; mod split; pub mod ssi; @@ -75,10 +79,18 @@ pub enum Error { Exception, /// The C API is a different version from the one this crate was written against. Version(i32), - /// This build has no SMLib (the `native` feature is off). + /// The C++ kernel was asked for on a build without it (the `native` feature, which the apps' + /// `smlib` feature turns on, is off): [`NOT_BUILT`]. NotBuilt, /// A B-rep record SMLib cannot take (see [`import::Unsupported`]). Record(import::Unsupported), + /// solidcore reported an error (the call, then its message). + Solidcore(String), + /// solidcore panicked; the guard caught it (the call, then the panic's message). + Internal(String), + /// A step of the C++ kernel solidcore has not ported yet, refused rather than built another + /// way: what is missing, and the solidcore issue that ports it. + Unported(&'static str), } impl std::fmt::Display for Error { @@ -114,8 +126,11 @@ impl std::fmt::Display for Error { } Error::Exception => write!(f, "SMLib raised an internal exception"), Error::Version(v) => write!(f, "SMLib C API version {v}, expected {}", ffi::ABI_VERSION), - Error::NotBuilt => write!(f, "not supported yet: this build has no SMLib"), + Error::NotBuilt => write!(f, "{NOT_BUILT}"), Error::Record(u) => write!(f, "{u}"), + Error::Solidcore(e) => write!(f, "solidcore: {e}"), + Error::Internal(e) => write!(f, "solidcore failed internally in {e}"), + Error::Unported(what) => write!(f, "not supported yet: {what}"), } } } @@ -124,6 +139,14 @@ impl std::error::Error for Error {} pub type Result = std::result::Result; +/// What [`Error::NotBuilt`] says: the C++ kernel is opt-in at build time, and how to opt in. +pub const NOT_BUILT: &str = "this build doesn't include the C++ SMLib kernel; build with --features smlib"; + +/// Whether this build links the C++ SMLib kernel (the `native` feature). +pub const fn built() -> bool { + cfg!(feature = "native") +} + fn error(s: i32) -> Error { match s { ffi::ERR_ARG | ffi::ERR_BUFFER => Error::Argument, @@ -182,6 +205,8 @@ static INIT_RESULT: AtomicI32 = AtomicI32::new(0); /// Checks the C API's version, creates SMLib's context and routes its log to the `log` crate. /// Every other call runs it first; calling it at start-up keeps the cost out of the first operation. pub fn init() -> Result<()> { + #[cfg(test)] + route::reached_cpp(); INIT.call_once(|| { let v = ffi::abi_version(); let result = if v == ffi::NOT_BUILT { @@ -201,24 +226,51 @@ pub fn init() -> Result<()> { } } -/// Where failing calls leave their inputs for replay (see the C API's `smlib_set_dump_dir`); -/// `None` turns that off. +/// Where failing calls leave their inputs for replay, on either kernel (see the C API's +/// `smlib_set_dump_dir`); `None` turns that off. A build without the C++ kernel has only solidcore's. pub fn set_dump_dir(dir: Option<&Path>) -> Result<()> { - init()?; let c = dir.map(c_path).transpose()?; - check(ffi::set_dump_dir(c.as_deref())) + core::dump::set_dir(dir.map(Path::to_path_buf)); + // A build or a run on solidcore alone never starts the C++ kernel to tell it. + if route::backend() != route::Backend::Smlib { + return Ok(()); + } + match init() { + Ok(()) => check(ffi::set_dump_dir(c.as_deref())), + Err(Error::NotBuilt) => Ok(()), + Err(e) => Err(e), + } } -/// The SMLib call running right now, if any: for crash reports. Takes no lock. +/// The kernel call running right now, on either kernel, if any: for crash reports. Takes no lock. pub fn current_call() -> Option { + if let Some(call) = core::dump::current() { + return Some(call); + } + if route::backend() != route::Backend::Smlib { + return None; + } let mut buf = [0u8; 512]; let n = ffi::current_call(&mut buf); let s = buf.get(..n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default(); (!s.is_empty()).then_some(s) } -/// An SMLib B-rep. Immutable: operations return new bodies. -pub struct Brep(ffi::Handle); +/// A B-rep in either kernel ([`route`]). Immutable: operations return new bodies. A body made +/// by one kernel is converted for the other the first time an operation there needs it, through the +/// flat arrays both understand, and keeps both. A process has one kernel at a time, and this stays +/// because that kernel can change under a live body: changing the preference or `diagnostics.kernel` +/// leaves bodies already computed as they are, so the next operation on one needs it on the new +/// kernel. The parity tests take solidcore's bodies to the C++ kernel the same way. +pub struct Brep { + cpp: OnceLock>, + core: OnceLock>, + /// The kernel that made it, whose tags [`Brep::history`] reads (the arrays carry none). + maker: route::Backend, + /// `Some(k)` for [`Brep::tagged`]`(k)`: its faces and edges stand for their own positions in + /// operand `k`, so the body converted for the other kernel is tagged the same way there. + operand: Option, +} impl std::fmt::Debug for Brep { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -227,26 +279,83 @@ impl std::fmt::Debug for Brep { } impl Brep { + /// A body the C++ kernel made. + fn made(h: ffi::Handle) -> Brep { + #[cfg(test)] + route::reached_cpp(); + Brep { cpp: OnceLock::from(Ok(h)), core: OnceLock::new(), maker: route::Backend::Smlib, operand: None } + } + + /// A body solidcore made. + fn made_core(b: core::Body) -> Brep { + Brep { cpp: OnceLock::new(), core: OnceLock::from(Ok(b)), maker: route::Backend::Solidcore, operand: None } + } + + /// The C++ kernel's body: converted from solidcore's the first time it is asked for (and + /// tagged as solidcore's is, for a [`Brep::tagged`] body). + fn cpp(&self) -> Result<&ffi::Handle> { + #[cfg(test)] + route::reached_cpp(); + let made = self.cpp.get_or_init(|| { + let core = self.core.get().ok_or(Error::Empty)?.as_ref().map_err(Clone::clone)?; + let a = core::to_arrays(core)?; + let h = flat::Flat::from_arrays(&a)?.import()?.cpp.into_inner().unwrap_or(Err(Error::Empty))?; + match self.operand { + Some(k) => ffi::history::tag(&h, i32::try_from(k).map_err(|_| Error::Argument)?).map_err(error), + None => Ok(h), + } + }); + made.as_ref().map_err(Clone::clone) + } + + /// solidcore's body: converted from the C++ kernel's the first time it is asked for (and + /// tagged as the C++ kernel's is, for a [`Brep::tagged`] body). + fn core(&self) -> Result<&core::Body> { + let made = self.core.get_or_init(|| { + self.cpp.get().ok_or(Error::Empty)?.as_ref().map_err(Clone::clone)?; + let b = core::from_arrays(&self.cpp_arrays()?)?; + match self.operand { + Some(k) => core::history::tag(&b, k), + None => Ok(b), + } + }); + made.as_ref().map_err(Clone::clone) + } + /// An axis-aligned box from its lower corner, with positive edge lengths (mm). pub fn make_box(origin: [f64; 3], size: [f64; 3]) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::make_box(origin, size).map(Brep::made_core); + } init()?; - ffi::make_box(&origin, &size).map(Brep).map_err(error) + ffi::make_box(&origin, &size).map(Brep::made).map_err(error) } /// Reads a native SMLib `.smb` file. pub fn read(path: &Path, ascii: bool) -> Result { + if route::backend() == route::Backend::Solidcore { + c_path(path)?; + return core::file::read(path, ascii).map(Brep::made_core); + } init()?; - ffi::read(&c_path(path)?, ascii).map(Brep).map_err(error) + ffi::read(&c_path(path)?, ascii).map(Brep::made).map_err(error) } /// Writes this body to a native SMLib `.smb` file. pub fn write(&self, path: &Path, ascii: bool) -> Result<()> { - check(ffi::write(&self.0, &c_path(path)?, ascii)) + if route::backend() == route::Backend::Solidcore { + c_path(path)?; + return core::file::write(self.core()?, path, ascii); + } + check(ffi::write(self.cpp()?, &c_path(path)?, ascii)) } /// A deep copy. pub fn try_clone(&self) -> Result { - ffi::copy(&self.0).map(Brep).map_err(error) + if route::backend() == route::Backend::Solidcore { + return core::transform::copy(self.core()?).map(Brep::made_core); + } + ffi::copy(self.cpp()?).map(Brep::made).map_err(error) } /// This body moved by the affine `matrix` (column-major: columns 0–2 the images of x, y and @@ -254,17 +363,26 @@ impl Brep { /// mirror it, a non-uniform scale or a shear turns its geometry into NURBS, tolerances scale /// with it. pub fn transformed(&self, matrix: &[[f64; 4]; 4]) -> Result { - ffi::transform(&self.0, matrix).map(Brep).map_err(error) + if route::backend() == route::Backend::Solidcore { + return core::transform::transform(self.core()?, matrix).map(Brep::made_core); + } + ffi::transform(self.cpp()?, matrix).map(Brep::made).map_err(error) } /// The enclosed volume (mm³) at `relative_accuracy` (SMLib clamps it to [1e-8, 1e-1]). pub fn volume(&self, relative_accuracy: f64) -> Result { - ffi::volume(&self.0, relative_accuracy).map_err(error) + if route::backend() == route::Backend::Solidcore { + return core::volume(self.core()?, relative_accuracy); + } + ffi::volume(self.cpp()?, relative_accuracy).map_err(error) } /// Face, edge and vertex counts. pub fn counts(&self) -> Result<[usize; 3]> { - let c = ffi::counts(&self.0).map_err(error)?; + if route::backend() == route::Backend::Solidcore { + return core::counts(self.core()?); + } + let c = ffi::counts(self.cpp()?).map_err(error)?; Ok(c.map(|n| usize::try_from(n).unwrap_or(0))) } } diff --git a/crates/smlib/src/measure.rs b/crates/smlib/src/measure.rs index 0509539f86..0a878ec562 100644 --- a/crates/smlib/src/measure.rs +++ b/crates/smlib/src/measure.rs @@ -2,7 +2,12 @@ //! surfaces rather than a tessellation; the exact box; how far an edge lies from a face's surface; //! where a point lies against a body. -use crate::{Brep, Result, error, ffi}; +use crate::{Brep, Result, core, error, ffi, route}; + +/// Whether the Measure family runs on solidcore ([`route`]). +fn on_core() -> bool { + route::backend() == route::Backend::Solidcore +} /// The accuracy [`Brep::mass_properties`] and [`Brep::area`] use unless told otherwise: the /// finest SMLib allows. Analytic primitives then come out within 2e-7 of their closed forms. @@ -50,7 +55,10 @@ impl Brep { /// else inside a material region or outside. A body with no material (a sheet, an open shell) /// has no inside. A non-finite coordinate is [`Error::Argument`](crate::Error::Argument). pub fn classify_point(&self, p: [f64; 3]) -> Result { - match ffi::measure::classify_point(&self.0, p).map_err(error)? { + if on_core() { + return core::classify_point(self.core()?, p); + } + match ffi::measure::classify_point(self.cpp()?, p).map_err(error)? { ffi::measure::POINT_OUTSIDE => Ok(PointClass::Outside), ffi::measure::POINT_INSIDE => Ok(PointClass::Inside), ffi::measure::POINT_ON_BOUNDARY => Ok(PointClass::OnBoundary), @@ -64,7 +72,10 @@ impl Brep { /// touching along edges or at points, measure too. A body with no material (a sheet, an open /// shell) has volume 0 and no inertia, its faces' area and the centroid of that area. pub fn mass_properties(&self, relative_accuracy: f64) -> Result { - let m = ffi::measure::mass_properties(&self.0, relative_accuracy).map_err(error)?; + if on_core() { + return core::mass_properties(self.core()?, relative_accuracy); + } + let m = ffi::measure::mass_properties(self.cpp()?, relative_accuracy).map_err(error)?; let [area, volume, cx, cy, cz, ixx, iyy, izz, iyz, izx, ixy] = m; Ok(MassProperties { area, volume, centroid: [cx, cy, cz], moments: [ixx, iyy, izz], products: [iyz, izx, ixy] }) } @@ -72,7 +83,10 @@ impl Brep { /// The exact axis-aligned box, least corner then greatest: the extrema of every vertex, edge /// and face on its exact curve or surface, not grown by tolerances or control hulls. pub fn bounds(&self) -> Result<([f64; 3], [f64; 3])> { - ffi::measure::bounds(&self.0).map_err(error) + if on_core() { + return core::bounds(self.core()?); + } + ffi::measure::bounds(self.cpp()?).map_err(error) } /// How far edge `edge` (its curve over its interval) lies from the surface of face `face`, @@ -82,13 +96,113 @@ impl Brep { /// goes; SMLib keeps such an edge whole when making its UV trim only when its zone tolerance /// (half the flat arrays' `EDGE_TOLERANCE`) covers this. pub fn edge_face_distance(&self, edge: usize, face: usize) -> Result { + if on_core() { + return core::edge_face_distance(self.core()?, edge, face); + } let (Ok(e), Ok(f)) = (i64::try_from(edge), i64::try_from(face)) else { return Err(error(ffi::ERR_ARG)) }; - ffi::measure::edge_face_distance(&self.0, e, f).map_err(error) + ffi::measure::edge_face_distance(self.cpp()?, e, f).map_err(error) } /// Total area of every face, each counted once, so sheets and open shells too. pub fn area(&self, relative_accuracy: f64) -> Result { - ffi::measure::area(&self.0, relative_accuracy).map_err(error) + if on_core() { + return core::area(self.core()?, relative_accuracy); + } + ffi::measure::area(self.cpp()?, relative_accuracy).map_err(error) + } +} + +/// The closed forms of the primitives' measures, for the tests of both kernels. +#[cfg(test)] +pub(crate) mod closed { + use std::f64::consts::PI; + + /// Area, volume, centroid, Ixx, Iyy, Izz about it (products are 0 for all), and the exact box. + pub(crate) struct Closed { + pub area: f64, + pub volume: f64, + pub centroid: [f64; 3], + pub moments: [f64; 3], + pub bounds: ([f64; 3], [f64; 3]), + } + + /// An axis-aligned box from its lower corner. + pub(crate) fn cuboid(o: [f64; 3], size: [f64; 3]) -> Closed { + let [a, b, c] = size; + let v = a * b * c; + Closed { + area: 2.0 * (a * b + b * c + c * a), + volume: v, + centroid: [o[0] + a / 2.0, o[1] + b / 2.0, o[2] + c / 2.0], + moments: [v * (b * b + c * c) / 12.0, v * (a * a + c * c) / 12.0, v * (a * a + b * b) / 12.0], + bounds: (o, [o[0] + a, o[1] + b, o[2] + c]), + } + } + + /// A cylinder about +Z on its base centre. + pub(crate) fn cylinder(o: [f64; 3], r: f64, h: f64) -> Closed { + let v = PI * r * r * h; + let ixx = v * (3.0 * r * r + h * h) / 12.0; + Closed { + area: 2.0 * PI * r * r + 2.0 * PI * r * h, + volume: v, + centroid: [o[0], o[1], o[2] + h / 2.0], + moments: [ixx, ixx, v * r * r / 2.0], + bounds: ([o[0] - r, o[1] - r, o[2]], [o[0] + r, o[1] + r, o[2] + h]), + } + } + + pub(crate) fn sphere(o: [f64; 3], r: f64) -> Closed { + let v = 4.0 / 3.0 * PI * r * r * r; + let i = 0.4 * v * r * r; + Closed { + area: 4.0 * PI * r * r, + volume: v, + centroid: o, + moments: [i; 3], + bounds: ([o[0] - r, o[1] - r, o[2] - r], [o[0] + r, o[1] + r, o[2] + r]), + } + } + + /// A frustum (a cone when `rt` is 0) about +Z on its base centre. Its volume integrals are + /// polynomials in z of degree at most 4, so three-point Gauss-Legendre gives them exactly. + pub(crate) fn cone(o: [f64; 3], rb: f64, rt: f64, h: f64) -> Closed { + let (x, w) = ([-(0.6f64).sqrt(), 0.0, (0.6f64).sqrt()], [5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0]); + let (mut v, mut mz, mut zz, mut izz) = (0.0, 0.0, 0.0, 0.0); + for (xi, wi) in x.iter().zip(w) { + let (z, wi) = (h / 2.0 * (1.0 + xi), wi * h / 2.0); + let rho = rb + (rt - rb) * z / h; + let disc = PI * rho * rho; + v += wi * disc; + mz += wi * disc * z; + zz += wi * disc * z * z; + izz += wi * disc * rho * rho / 2.0; + } + let zc = mz / v; + let ixx = izz / 2.0 + zz - v * zc * zc; + let s = ((rb - rt).powi(2) + h * h).sqrt(); + let m = rb.max(rt); + Closed { + area: PI * (rb * rb + rt * rt + (rb + rt) * s), + volume: v, + centroid: [o[0], o[1], o[2] + zc], + moments: [ixx, ixx, izz], + bounds: ([o[0] - m, o[1] - m, o[2]], [o[0] + m, o[1] + m, o[2] + h]), + } + } + + /// A ring torus about +Z (Pappus for area and volume). + pub(crate) fn torus(o: [f64; 3], big: f64, r: f64) -> Closed { + let v = 2.0 * PI * PI * big * r * r; + let ixx = v * (big * big / 2.0 + 5.0 * r * r / 8.0); + let m = big + r; + Closed { + area: 4.0 * PI * PI * big * r, + volume: v, + centroid: o, + moments: [ixx, ixx, v * (big * big + 0.75 * r * r)], + bounds: ([o[0] - m, o[1] - m, o[2] - r], [o[0] + m, o[1] + m, o[2] + r]), + } } } @@ -96,8 +210,6 @@ impl Brep { mod tests { use super::*; use crate::Error; - use std::f64::consts::PI; - fn rel(a: f64, b: f64) -> f64 { if b == 0.0 { a.abs() } else { (a / b - 1.0).abs() } } @@ -152,7 +264,9 @@ mod tests { v } - /// Closed forms: area, volume, centroid and Ixx, Iyy, Izz about it (products are 0 for all). + use closed::Closed; + + /// A primitive the C++ kernel made, with its closed forms. struct Expected { name: &'static str, body: Brep, @@ -163,67 +277,24 @@ mod tests { } #[allow(clippy::unwrap_used)] // test fixture + fn expected(name: &'static str, body: Result, c: Closed) -> Expected { + Expected { name, body: body.unwrap(), area: c.area, volume: c.volume, centroid: c.centroid, moments: c.moments } + } + fn cylinder(name: &'static str, o: [f64; 3], r: f64, h: f64) -> Expected { - let v = PI * r * r * h; - let ixx = v * (3.0 * r * r + h * h) / 12.0; - Expected { - name, - body: Brep::make_cylinder(o, r, h).unwrap(), - area: 2.0 * PI * r * r + 2.0 * PI * r * h, - volume: v, - centroid: [o[0], o[1], o[2] + h / 2.0], - moments: [ixx, ixx, v * r * r / 2.0], - } + expected(name, Brep::make_cylinder(o, r, h), closed::cylinder(o, r, h)) } - #[allow(clippy::unwrap_used)] // test fixture fn sphere(name: &'static str, o: [f64; 3], r: f64) -> Expected { - let v = 4.0 / 3.0 * PI * r * r * r; - let i = 0.4 * v * r * r; - Expected { name, body: Brep::make_sphere(o, r).unwrap(), area: 4.0 * PI * r * r, volume: v, centroid: o, moments: [i; 3] } + expected(name, Brep::make_sphere(o, r), closed::sphere(o, r)) } - /// A frustum (a cone when `rt` is 0) about +Z on its base centre. Its volume integrals are - /// polynomials in z of degree at most 4, so three-point Gauss-Legendre gives them exactly. - #[allow(clippy::unwrap_used)] // test fixture fn cone(name: &'static str, o: [f64; 3], rb: f64, rt: f64, h: f64) -> Expected { - let (x, w) = ([-(0.6f64).sqrt(), 0.0, (0.6f64).sqrt()], [5.0 / 9.0, 8.0 / 9.0, 5.0 / 9.0]); - let (mut v, mut mz, mut zz, mut izz) = (0.0, 0.0, 0.0, 0.0); - for i in 0..3 { - let (z, wi) = (h / 2.0 * (1.0 + x[i]), w[i] * h / 2.0); - let rho = rb + (rt - rb) * z / h; - let disc = PI * rho * rho; - v += wi * disc; - mz += wi * disc * z; - zz += wi * disc * z * z; - izz += wi * disc * rho * rho / 2.0; - } - let zc = mz / v; - let ixx = izz / 2.0 + zz - v * zc * zc; - let s = ((rb - rt).powi(2) + h * h).sqrt(); - Expected { - name, - body: Brep::make_cone(o, rb, rt, h).unwrap(), - area: PI * (rb * rb + rt * rt + (rb + rt) * s), - volume: v, - centroid: [o[0], o[1], o[2] + zc], - moments: [ixx, ixx, izz], - } + expected(name, Brep::make_cone(o, rb, rt, h), closed::cone(o, rb, rt, h)) } - /// A ring torus about +Z (Pappus for area and volume). - #[allow(clippy::unwrap_used)] // test fixture fn torus(name: &'static str, o: [f64; 3], big: f64, r: f64) -> Expected { - let v = 2.0 * PI * PI * big * r * r; - let ixx = v * (big * big / 2.0 + 5.0 * r * r / 8.0); - Expected { - name, - body: Brep::make_torus(o, big, r).unwrap(), - area: 4.0 * PI * PI * big * r, - volume: v, - centroid: o, - moments: [ixx, ixx, v * (big * big + 0.75 * r * r)], - } + expected(name, Brep::make_torus(o, big, r), closed::torus(o, big, r)) } /// Every primitive against its closed form, at the origin and 2e5 mm out, to 1e-6 (tests/oracle diff --git a/crates/smlib/src/ops.rs b/crates/smlib/src/ops.rs index 83f41e12de..f782ee6594 100644 --- a/crates/smlib/src/ops.rs +++ b/crates/smlib/src/ops.rs @@ -6,7 +6,7 @@ //! edge (face) nearest to it, which must lie within `tolerance` of it and be nearer than any other //! by more than the body's modelling tolerance ([`Error::NoMatch`], [`Error::Ambiguous`]). -use crate::{Brep, Error, Result, error, ffi, init}; +use crate::{Brep, Error, Result, core, error, ffi, init, route}; /// What size builds a blend SMLib refused ([`blend_fit`]): from the refusal's own cause, never by /// building again. @@ -27,6 +27,11 @@ pub enum Fit { /// ([`Error::NoRoom`]) or whose setback it refused as too short; `None` where it names no single /// size. Read it right after the refused call: SMLib keeps only the last one. pub fn blend_fit() -> Option { + // solidcore names no size that builds yet: its refusals keep their own words, and the C++ + // kernel's last refusal is never read for one of solidcore's. + if route::backend() == route::Backend::Solidcore { + return None; + } let (size, what) = ffi::ops::blend_fit_size()?; match what { ffi::ops::FIT_SIZE => Some(Fit::Size(size)), @@ -76,7 +81,7 @@ pub const MAX_UNION_BREPS: usize = 100_000; /// Most points one selection may hold (the C API's limit). pub const MAX_POINTS: usize = 4096; -fn pick_error((s, at): ffi::ops::PickError) -> Error { +pub(crate) fn pick_error((s, at): ffi::ops::PickError) -> Error { match (s, at) { (ffi::ops::ERR_PICK_NONE, Some(point)) => Error::NoMatch { point }, (ffi::ops::ERR_PICK_AMBIGUOUS, Some(point)) => Error::Ambiguous { point }, @@ -84,7 +89,7 @@ fn pick_error((s, at): ffi::ops::PickError) -> Error { } } -fn setback_error((s, at, corner): ffi::ops::SetbackError) -> Error { +pub(crate) fn setback_error((s, at, corner): ffi::ops::SetbackError) -> Error { match corner { Some(corner) => Error::CornerRefused { corner }, None => pick_error((s, at)), @@ -131,14 +136,17 @@ impl Brep { /// as one body, counted by [`Brep::solid_count`]. Tagged operands ([`Brep::tagged`]) give a /// result whose [`Brep::history`] says where each face and edge came from. pub fn boolean(&self, other: &Brep, op: BooleanOp) -> Result> { + if route::backend() == route::Backend::Solidcore { + return core::boolean::boolean(self.core()?, other.core()?, op).map(|r| r.map(Brep::made_core)); + } init()?; let op = match op { BooleanOp::Union => ffi::ops::BOOLEAN_UNION, BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT, BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT, }; - match ffi::ops::boolean(&self.0, &other.0, op) { - Ok(h) => Ok(Some(Brep(h))), + match ffi::ops::boolean(self.cpp()?, other.cpp()?, op) { + Ok(h) => Ok(Some(Brep::made(h))), Err(ffi::ERR_EMPTY) => Ok(None), Err(s) => Err(error(s)), } @@ -149,26 +157,36 @@ impl Brep { /// tagged each with its own number ([`Brep::tagged`]) give a result whose [`Brep::history`] /// names them. At least one body and at most [`MAX_UNION_BREPS`]. pub fn union_all(breps: &[&Brep]) -> Result { + if route::backend() == route::Backend::Solidcore { + if breps.is_empty() || breps.len() > MAX_UNION_BREPS { + return Err(Error::Argument); + } + let bodies = breps.iter().map(|b| b.core()).collect::>>()?; + return core::boolean::union_all(&bodies).map(Brep::made_core); + } init()?; if breps.is_empty() || breps.len() > MAX_UNION_BREPS { return Err(error(ffi::ERR_ARG)); } - let handles: Vec<&ffi::Handle> = breps.iter().map(|b| &b.0).collect(); - ffi::ops::boolean_union_all(&handles).map(Brep).map_err(error) + let handles = breps.iter().map(|b| b.cpp()).collect::>>()?; + ffi::ops::boolean_union_all(&handles).map(Brep::made).map_err(error) } /// `self` and `other`, sheets of planar faces on one plane ([`Brep::planar_faces`]), combined /// as regions of that plane by `op`: `None` when the result is empty. The result's edges are /// the inputs' own curves, split where they cross. pub fn boolean2d(&self, other: &Brep, op: BooleanOp) -> Result> { + if route::backend() == route::Backend::Solidcore { + return core::boolean::boolean2d(self.core()?, other.core()?, op).map(|r| r.map(Brep::made_core)); + } init()?; let op = match op { BooleanOp::Union => ffi::ops::BOOLEAN_UNION, BooleanOp::Subtract => ffi::ops::BOOLEAN_SUBTRACT, BooleanOp::Intersect => ffi::ops::BOOLEAN_INTERSECT, }; - match ffi::ops::boolean2d(&self.0, &other.0, op) { - Ok(h) => Ok(Some(Brep(h))), + match ffi::ops::boolean2d(self.cpp()?, other.cpp()?, op) { + Ok(h) => Ok(Some(Brep::made(h))), Err(ffi::ERR_EMPTY) => Ok(None), Err(s) => Err(error(s)), } @@ -178,9 +196,12 @@ impl Brep { /// one body: `None` when the tool meets no face. Tagged operands give the pieces' history /// (each piece is a split of the face it was cut from). pub fn imprint(&self, tool: &Brep) -> Result> { + if route::backend() == route::Backend::Solidcore { + return core::boolean::imprint(self.core()?, tool.core()?).map(|r| r.map(Brep::made_core)); + } init()?; - match ffi::ops::imprint(&self.0, &tool.0) { - Ok(h) => Ok(Some(Brep(h))), + match ffi::ops::imprint(self.cpp()?, tool.cpp()?) { + Ok(h) => Ok(Some(Brep::made(h))), Err(ffi::ops::ERR_NO_CUT) => Ok(None), Err(s) => Err(error(s)), } @@ -188,12 +209,18 @@ impl Brep { /// The number of separate solid regions (voids not counted). pub fn solid_count(&self) -> Result { - ffi::ops::solid_count(&self.0).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error) + if route::backend() == route::Backend::Solidcore { + return crate::core::solid_count(self.core()?); + } + ffi::ops::solid_count(self.cpp()?).map(|n| usize::try_from(n).unwrap_or(0)).map_err(error) } /// The edges nearest to `edges` (each within `tolerance`), exactly those, rounded /// at `radius`. pub fn fillet(&self, edges: &[[f64; 3]], tolerance: f64, radius: f64) -> Result { + if route::backend() == route::Backend::Solidcore { + return self.core_blend(Blend::Round, edges, &[radius], tolerance, &[], &[], core::blend::Corners::Filled); + } self.pick_op(ffi::ops::Pick::Fillet, edges, tolerance, radius, false) } @@ -201,40 +228,49 @@ impl Brep { /// an equal-distance chamfer `distance` back along each face (measured at each edge's /// midpoint), with bevelled corners. pub fn chamfer(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64) -> Result { + if route::backend() == route::Backend::Solidcore { + return self.core_blend(Blend::Chamfer, edges, &[distance], tolerance, &[], &[], core::blend::Corners::Filled); + } self.pick_op(ffi::ops::Pick::Chamfer, edges, tolerance, distance, false) } /// The edges nearest to `edges` (each within `tolerance`), exactly those, blended /// as `kind`, edge i at `sizes[i]`. pub fn blend(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result { - init()?; if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() { return Err(Error::Argument); } + if route::backend() == route::Backend::Solidcore { + return self.core_blend(kind, edges, sizes, tolerance, &[], &[], core::blend::Corners::Filled); + } + init()?; let kind = match kind { Blend::Round => ffi::ops::BLEND_ROUND, Blend::Curvature => ffi::ops::BLEND_CURVATURE, Blend::Chord => ffi::ops::BLEND_CHORD, Blend::Chamfer => ffi::ops::BLEND_CHAMFER, }; - ffi::ops::blend(kind, &self.0, edges, sizes, tolerance).map(Brep).map_err(pick_error) + ffi::ops::blend(kind, self.cpp()?, edges, sizes, tolerance).map(Brep::made).map_err(pick_error) } /// [`Brep::blend`], with whether each point picked an edge that a larger round, overflowing /// its faces, removed whole: that edge is gone and not rounded (SMLib rounds the edges whose /// blends overflow first, then what is left of the others). pub fn blend_dropping(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Brep, Vec)> { - init()?; if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() { return Err(Error::Argument); } + if route::backend() == route::Backend::Solidcore { + return core::blend::blend_dropping(self.core()?, kind, edges, sizes, tolerance).map(|(b, d)| (Brep::made_core(b), d)); + } + init()?; let kind = match kind { Blend::Round => ffi::ops::BLEND_ROUND, Blend::Curvature => ffi::ops::BLEND_CURVATURE, Blend::Chord => ffi::ops::BLEND_CHORD, Blend::Chamfer => ffi::ops::BLEND_CHAMFER, }; - ffi::ops::blend_dropping(kind, &self.0, edges, sizes, tolerance).map(|(b, d)| (Brep(b), d)).map_err(pick_error) + ffi::ops::blend_dropping(kind, self.cpp()?, edges, sizes, tolerance).map(|(b, d)| (Brep::made(b), d)).map_err(pick_error) } /// [`Brep::blend`] with corner setbacks: each of `corners` picks the vertex nearest it @@ -251,7 +287,6 @@ impl Brep { setbacks: &[f64], tolerance: f64, ) -> Result { - init()?; if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() @@ -261,13 +296,17 @@ impl Brep { { return Err(Error::Argument); } + if route::backend() == route::Backend::Solidcore { + return self.core_blend(kind, edges, sizes, tolerance, corners, setbacks, core::blend::Corners::Filled); + } + init()?; let kind = match kind { Blend::Round => ffi::ops::BLEND_ROUND, Blend::Curvature => ffi::ops::BLEND_CURVATURE, Blend::Chord => ffi::ops::BLEND_CHORD, Blend::Chamfer => ffi::ops::BLEND_CHAMFER, }; - ffi::ops::blend_setback(kind, &self.0, edges, sizes, corners, setbacks, tolerance).map(Brep).map_err(setback_error) + ffi::ops::blend_setback(kind, self.cpp()?, edges, sizes, corners, setbacks, tolerance).map(Brep::made).map_err(setback_error) } /// [`Brep::blend_setback`] with each set-back corner left open: SMLib's patch between the @@ -284,7 +323,6 @@ impl Brep { setbacks: &[f64], tolerance: f64, ) -> Result<(Brep, Vec)> { - init()?; if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() @@ -294,16 +332,21 @@ impl Brep { { return Err(Error::Argument); } + if route::backend() == route::Backend::Solidcore { + return core::blend::blend_holes(self.core()?, kind, edges, sizes, tolerance, corners, setbacks, core::blend::Corners::Open) + .map(|(b, holes)| (Brep::made_core(b), holes)); + } + init()?; let kind = match kind { Blend::Round => ffi::ops::BLEND_ROUND, Blend::Curvature => ffi::ops::BLEND_CURVATURE, Blend::Chord => ffi::ops::BLEND_CHORD, Blend::Chamfer => ffi::ops::BLEND_CHAMFER, }; - let (h, values) = ffi::ops::blend_setback_open(kind, &self.0, edges, sizes, corners, setbacks, tolerance).map_err(setback_error)?; + let (h, values) = ffi::ops::blend_setback_open(kind, self.cpp()?, edges, sizes, corners, setbacks, tolerance).map_err(setback_error)?; // A buffer that does not read as the C API documents it is a buffer error. let holes = holes(&values, corners.len()).ok_or_else(|| error(ffi::ERR_BUFFER))?; - Ok((Brep(h), holes)) + Ok((Brep::made(h), holes)) } /// The one edge nearest to `edge` (within `tolerance`) rounded with a radius @@ -311,9 +354,12 @@ impl Brep { /// `radii` over the position along the edge, 0 at its end nearest `start` and 1 at the other /// (the edge curve's parameter scaled to 0..1). pub fn fillet_variable(&self, edge: [f64; 3], tolerance: f64, start: [f64; 3], degree: usize, knots: &[f64], radii: &[f64]) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::blend::fillet_variable(self.core()?, edge, tolerance, start, degree, knots, radii).map(Brep::made_core); + } init()?; let degree = i32::try_from(degree).map_err(|_| Error::Argument)?; - ffi::ops::fillet_variable(&self.0, &edge, tolerance, &start, degree, knots, radii).map(Brep).map_err(|s| match s { + ffi::ops::fillet_variable(self.cpp()?, &edge, tolerance, &start, degree, knots, radii).map(Brep::made).map_err(|s| match s { ffi::ops::ERR_PICK_NONE => Error::NoMatch { point: 0 }, ffi::ops::ERR_PICK_AMBIGUOUS => Error::Ambiguous { point: 0 }, s => error(s), @@ -325,15 +371,18 @@ impl Brep { /// and by `second` on the other, with bevelled corners (setbacks measured at each edge's /// midpoint). pub fn chamfer_sides(&self, edges: &[[f64; 3]], tolerance: f64, distance: f64, second: ChamferSecond, first: [f64; 3]) -> Result { - init()?; if edges.is_empty() || edges.len() > MAX_POINTS { return Err(Error::Argument); } + if route::backend() == route::Backend::Solidcore { + return core::blend::chamfer_sides(self.core()?, edges, tolerance, distance, second, first).map(Brep::made_core); + } + init()?; let (value, angle) = match second { ChamferSecond::Distance(d) => (d, false), ChamferSecond::Angle(a) => (a, true), }; - ffi::ops::chamfer_sides(&self.0, edges, tolerance, distance, value, angle, &first).map(Brep).map_err(pick_error) + ffi::ops::chamfer_sides(self.cpp()?, edges, tolerance, distance, value, angle, &first).map(Brep::made).map_err(pick_error) } /// Hollowed to a wall of `thickness` (negative: inside the original surface, positive: @@ -342,6 +391,9 @@ impl Brep { /// whose walls would just meet (a hole exactly twice the wall from a side) is /// [`Error::WallsTouch`]. pub fn shell(&self, open_faces: &[[f64; 3]], tolerance: f64, thickness: f64) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::offset::shell(self.core()?, open_faces, tolerance, thickness).map(Brep::made_core); + } self.pick_op(ffi::ops::Pick::Shell, open_faces, tolerance, thickness, true) } @@ -350,6 +402,9 @@ impl Brep { /// faces around them extended or trimmed to meet them. Faces moved in past all of the body /// behind them are [`Error::NoRoom`]. pub fn offset_faces(&self, faces: &[[f64; 3]], tolerance: f64, distance: f64) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::offset::offset_faces(self.core()?, faces, tolerance, distance).map(Brep::made_core); + } self.pick_op(ffi::ops::Pick::OffsetFaces, faces, tolerance, distance, false) } @@ -357,8 +412,11 @@ impl Brep { /// against them), or by half each way when `symmetric`, with ruled walls along its open /// edges. A closed solid is [`Error::Argument`]. pub fn thicken(&self, thickness: f64, symmetric: bool) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::offset::thicken(self.core()?, thickness, symmetric).map(Brep::made_core); + } init()?; - ffi::ops::thicken(&self.0, thickness, symmetric).map(Brep).map_err(error) + ffi::ops::thicken(self.cpp()?, thickness, symmetric).map(Brep::made).map_err(error) } /// This body with its topological edges and vertices removed (SMLib's @@ -367,11 +425,14 @@ impl Brep { /// The edges nearest to `keep` (each within `tolerance`) stay. The shape is unchanged, and /// SMLib's history tags follow the merge. pub fn merge_faces(&self, keep: &[[f64; 3]], tolerance: f64) -> Result { - init()?; if keep.len() > MAX_POINTS { return Err(Error::Argument); } - ffi::ops::merge_faces(&self.0, keep, tolerance).map(Brep).map_err(pick_error) + if route::backend() == route::Backend::Solidcore { + return core::merge::merge_faces(self.core()?, keep, tolerance).map(Brep::made_core); + } + init()?; + ffi::ops::merge_faces(self.cpp()?, keep, tolerance).map(Brep::made).map_err(pick_error) } /// The flat faces nearest to `faces` (each within `tolerance`) tilted by `angle` radians @@ -388,11 +449,32 @@ impl Brep { pull: [f64; 3], angle: f64, ) -> Result { - init()?; if faces.is_empty() || faces.len() > MAX_POINTS { return Err(Error::Argument); } - ffi::ops::draft(&self.0, faces, tolerance, &neutral_origin, &neutral_normal, &pull, angle).map(Brep).map_err(pick_error) + if route::backend() == route::Backend::Solidcore { + return core::offset::draft(self.core()?, faces, tolerance, neutral_origin, neutral_normal, pull, angle).map(Brep::made_core); + } + init()?; + ffi::ops::draft(self.cpp()?, faces, tolerance, &neutral_origin, &neutral_normal, &pull, angle).map(Brep::made).map_err(pick_error) + } + + /// [`Brep::blend`] and its kin on solidcore (`core::blend::blend`). + #[allow(clippy::too_many_arguments)] + fn core_blend( + &self, + kind: Blend, + edges: &[[f64; 3]], + sizes: &[f64], + tolerance: f64, + corners: &[[f64; 3]], + setbacks: &[f64], + open: core::blend::Corners, + ) -> Result { + if edges.len() > MAX_POINTS || edges.is_empty() { + return Err(Error::Argument); + } + core::blend::blend(self.core()?, kind, edges, sizes, tolerance, corners, setbacks, open).map(Brep::made_core) } fn pick_op(&self, kind: ffi::ops::Pick, points: &[[f64; 3]], tolerance: f64, size: f64, allow_none: bool) -> Result { @@ -400,7 +482,7 @@ impl Brep { if points.len() > MAX_POINTS || (points.is_empty() && !allow_none) { return Err(Error::Argument); } - ffi::ops::pick_op(kind, &self.0, points, tolerance, size).map(Brep).map_err(pick_error) + ffi::ops::pick_op(kind, self.cpp()?, points, tolerance, size).map(Brep::made).map_err(pick_error) } } diff --git a/crates/smlib/src/ops_tests.rs b/crates/smlib/src/ops_tests.rs index 0260ac40e2..785b4f9b73 100644 --- a/crates/smlib/src/ops_tests.rs +++ b/crates/smlib/src/ops_tests.rs @@ -402,7 +402,8 @@ fn a_setback_corner_left_open_returns_its_hole() { let (open, holes) = b.blend_setback_open(Blend::Round, &edges, &[5.0; 3], &[[60.0, 40.0, 20.0]], &[8.0], 0.1).unwrap(); // The call is a profiler span like every other, timed by the C API. let report = mark.report(); - assert!(report.rows.iter().any(|r| r.category == "smlib" && r.name == "smlib_blend_setback_open"), "{report:?}"); + let on_cpp = crate::route::backend() == crate::route::Backend::Smlib; + assert!(!on_cpp || report.rows.iter().any(|r| r.category == "smlib" && r.name == "smlib_blend_setback_open"), "{report:?}"); shot(&open, "setback_open"); assert_eq!(open.counts().unwrap(), [9, 30, 22]); assert!(!open.is_manifold_solid().unwrap()); diff --git a/crates/smlib/src/pcurves.rs b/crates/smlib/src/pcurves.rs index 3fd64a8f75..19554749d5 100644 --- a/crates/smlib/src/pcurves.rs +++ b/crates/smlib/src/pcurves.rs @@ -22,7 +22,7 @@ impl Trims for SmlibTrims { fn trims(face: &TrimFace<'_>) -> Result>, String> { let (record, bare) = sheet(face)?; let mut arrays = crate::import::arrays_with_bare_uses(&record, &bare).map_err(|e| e.to_string())?; - let import = |a: &crate::import::Arrays| crate::flat::Flat::from_arrays(a).and_then(|f| f.import()).map_err(|e| e.to_string()); + let import = |a: &crate::import::Arrays| crate::Brep::from_arrays(a).map_err(|e| e.to_string()); let mut body = import(&arrays)?; if cover_gaps(&body, face, &bare, &mut arrays)? { body = import(&arrays)?; diff --git a/crates/smlib/src/route.rs b/crates/smlib/src/route.rs new file mode 100644 index 0000000000..60cff4d62e --- /dev/null +++ b/crates/smlib/src/route.rs @@ -0,0 +1,171 @@ +//! Which kernel this process runs on: solidcore, the Rust port of SMLib, unless the C++ SMLib +//! fork is asked for. One kernel answers every operation. +//! +//! The C++ kernel is an opt-in: for a test, a rig or a user who wants the old behaviour. A call that +//! fails on solidcore fails, and a path solidcore has no answer for says it is not supported: there is +//! no fallback to the other kernel at run time, which would hide its bugs. Asking for the C++ kernel on +//! a build without it (the default, wasm included) stays on it too: every call answers +//! [`crate::Error::NotBuilt`], which says to build with `--features smlib`. +//! +//! `SOLIDCRAFT_KERNEL=smlib|solidcore` chooses for the whole process (the rig, the test matrix); +//! otherwise the preference ([`set_choice`]) does, and it defaults to solidcore. + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// A kernel the process runs on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Backend { + /// The C++ SMLib fork (tangency-labs/smlib) behind its C API. + Smlib, + /// solidcore (tangency-labs/solidcore), the Rust port of SMLib. + Solidcore, +} + +impl Backend { + /// The id `SOLIDCRAFT_KERNEL`, preferences and reports spell it with. + pub fn id(self) -> &'static str { + match self { + Backend::Smlib => "smlib", + Backend::Solidcore => "solidcore", + } + } + + /// Back from an id; anything else is `None`. + pub fn from_id(id: &str) -> Option { + [Backend::Smlib, Backend::Solidcore].into_iter().find(|b| b.id() == id) + } +} + +#[cfg(test)] +thread_local! { + /// Tests: this thread's operations run on the C++ kernel whatever the choice. + static ON_CPP: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: how many times this thread has touched a C++ body, and called solidcore. + static REACHED: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// Tests: this thread has touched a C++ body. +#[cfg(test)] +pub(crate) fn reached_cpp() { + REACHED.with(|r| r.set((r.get().0 + 1, r.get().1))); +} + +/// Tests: this thread has called solidcore. +#[cfg(test)] +pub(crate) fn reached_core() { + REACHED.with(|r| r.set((r.get().0, r.get().1 + 1))); +} + +/// Tests: how many times this thread has touched a C++ body and called solidcore since the last +/// time this was asked. +#[cfg(test)] +pub(crate) fn take_reached() -> (u64, u64) { + REACHED.with(|r| r.replace((0, 0))) +} + +/// Tests that exercise the C++ kernel opt in: until the guard drops, this thread's operations run +/// on it. Thread by thread, so that the other tests in the binary stay on the process's kernel. The +/// guard fails a test that never touched a C++ body, so a parity test can't quietly compare +/// solidcore with itself. +#[cfg(test)] +#[cfg_attr(not(feature = "native"), allow(dead_code))] +pub(crate) fn on_cpp() -> impl Drop { + struct Guard; + impl Drop for Guard { + fn drop(&mut self) { + ON_CPP.with(|c| c.set(false)); + let (cpp, core) = REACHED.with(std::cell::Cell::get); + if std::env::var_os("SOLIDCRAFT_TEST_REACHED").is_some() { + eprintln!("REACHED {} cpp={cpp} core={core}", std::thread::current().name().unwrap_or("?")); + } + assert!(cpp > 0 || std::thread::panicking(), "this test opted in to the C++ kernel and never reached it"); + } + } + ON_CPP.with(|c| c.set(true)); + REACHED.with(|r| r.set((0, 0))); + Guard +} + +/// The kernel that answers now: `SOLIDCRAFT_KERNEL`, else the preference, else solidcore. +pub fn backend() -> Backend { + #[cfg(test)] + if ON_CPP.with(std::cell::Cell::get) { + return Backend::Smlib; + } + choice() +} + +/// `SOLIDCRAFT_KERNEL`, read once. An unknown value is no override (and says so in the log). +fn environment() -> Option { + static ENV: OnceLock> = OnceLock::new(); + *ENV.get_or_init(|| { + let v = std::env::var("SOLIDCRAFT_KERNEL").ok()?; + let c = Backend::from_id(&v); + if c.is_none() { + log::warn!(target: "smlib", "SOLIDCRAFT_KERNEL={v}: not smlib or solidcore; ignored"); + } + if c == Some(Backend::Smlib) && !crate::built() { + log::warn!(target: "smlib", "SOLIDCRAFT_KERNEL=smlib: {}; every operation will be refused", crate::NOT_BUILT); + } + c + }) +} + +/// The preference: false for solidcore, true for the C++ kernel. +static PREFERS_CPP: AtomicBool = AtomicBool::new(false); + +/// The choice in force: `SOLIDCRAFT_KERNEL` when it is set, else the preference. +pub fn choice() -> Backend { + environment().unwrap_or_else(|| if PREFERS_CPP.load(Ordering::Relaxed) { Backend::Smlib } else { Backend::Solidcore }) +} + +/// Sets the preference. `SOLIDCRAFT_KERNEL`, when set, still wins. +pub fn set_choice(b: Backend) { + PREFERS_CPP.store(b == Backend::Smlib, Ordering::Relaxed); +} + +/// Whether `SOLIDCRAFT_KERNEL` overrides the preference in this process. +pub fn overridden() -> bool { + environment().is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The two ids round trip; `routed`, the old default, is not one of them. + #[test] + fn ids_round_trip() { + for b in [Backend::Smlib, Backend::Solidcore] { + assert_eq!(Backend::from_id(b.id()), Some(b)); + } + for other in ["routed", "truck", "SMLIB", ""] { + assert_eq!(Backend::from_id(other), None, "{other}"); + } + } + + /// With nothing chosen the process is on solidcore. (Only solidcore is set here: the other tests + /// in this binary build bodies meanwhile.) + #[test] + fn the_default_is_solidcore() { + if overridden() { + return; + } + set_choice(Backend::Solidcore); + assert_eq!((choice(), backend()), (Backend::Solidcore, Backend::Solidcore)); + } + + /// The guard moves this thread alone onto the C++ kernel, and back when it drops. + #[test] + fn a_test_opts_in_to_the_cpp_kernel_thread_by_thread() { + let before = backend(); + { + let _cpp = on_cpp(); + reached_cpp(); + assert_eq!(backend(), Backend::Smlib); + assert_eq!(choice(), before); + } + assert_eq!(backend(), before); + } +} diff --git a/crates/smlib/src/sheets.rs b/crates/smlib/src/sheets.rs index f05306f440..b2540bdb4f 100644 --- a/crates/smlib/src/sheets.rs +++ b/crates/smlib/src/sheets.rs @@ -3,21 +3,27 @@ //! tagged inputs ([`Brep::tagged`]) give results whose [`Brep::history`] says where each face and //! edge came from. -use crate::{Brep, Result, error, ffi, init}; +use crate::{Brep, Result, core, error, ffi, init, route}; impl Brep { /// The faces sewn along the vertices and edges that coincide within `tolerance` mm, oriented /// outward where they close into a solid, else only made to agree across their shared edges; /// with whether the result is a closed solid. pub fn stitch(&self, tolerance: f64) -> Result<(Brep, bool)> { + if route::backend() == route::Backend::Solidcore { + return core::merge::stitch(self.core()?, tolerance).map(|(b, closed)| (Brep::made_core(b), closed)); + } init()?; - ffi::sheets::stitch(&self.0, tolerance).map(|(h, closed)| (Brep(h), closed)).map_err(error) + ffi::sheets::stitch(self.cpp()?, tolerance).map(|(h, closed)| (Brep::made(h), closed)).map_err(error) } /// This sheet with every face moved onto its surface's offset by `distance` along its normal /// (negative: against it). A closed solid is [`crate::Error::Argument`]. pub fn offset_sheet(&self, distance: f64) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::offset::offset_sheet(self.core()?, distance).map(Brep::made_core); + } init()?; - ffi::sheets::offset_sheet(&self.0, distance).map(Brep).map_err(error) + ffi::sheets::offset_sheet(self.cpp()?, distance).map(Brep::made).map_err(error) } } diff --git a/crates/smlib/src/split.rs b/crates/smlib/src/split.rs index 0ca7b02991..a3dbba8766 100644 --- a/crates/smlib/src/split.rs +++ b/crates/smlib/src/split.rs @@ -3,7 +3,7 @@ //! taken over its whole domain. use crate::ssi::Surface; -use crate::{Brep, Error, Result, error, ffi, init}; +use crate::{Brep, Error, Result, core, error, ffi, init, route}; impl Brep { /// `self` with faces `faces` (indices in SMLib's face order, distinct) cut where each of @@ -11,14 +11,18 @@ impl Brep { /// for each face how many faces it became besides itself (0: no tool split it). `None` when /// no face is split. Tagged operands ([`Brep::tagged`]) give the pieces' history. pub fn split_faces(&self, faces: &[usize], tools: &[&Surface]) -> Result)>> { - init()?; if faces.is_empty() || faces.len() > ffi::split::MAX_FACES || tools.is_empty() || tools.len() > ffi::split::MAX_TOOLS { return Err(Error::Argument); } + if route::backend() == route::Backend::Solidcore { + let specs: Vec<&crate::ssi::Spec> = tools.iter().map(|t| t.spec()).collect(); + return core::split::split_faces(self.core()?, faces, &specs).map(|r| r.map(|(b, made)| (Brep::made_core(b), made))); + } + init()?; let faces: Vec = faces.iter().map(|f| i64::try_from(*f).map_err(|_| Error::Argument)).collect::>()?; - let handles: Vec<&ffi::ssi::SurfaceHandle> = tools.iter().map(|t| t.handle()).collect(); - match ffi::split::split_faces(&self.0, &faces, &handles) { - Ok((h, made)) => Ok(Some((Brep(h), made.into_iter().map(|m| usize::try_from(m).unwrap_or(0)).collect()))), + let handles: Vec<&ffi::ssi::SurfaceHandle> = tools.iter().map(|t| t.handle()).collect::>()?; + match ffi::split::split_faces(self.cpp()?, &faces, &handles) { + Ok((h, made)) => Ok(Some((Brep::made(h), made.into_iter().map(|m| usize::try_from(m).unwrap_or(0)).collect()))), Err(ffi::ops::ERR_NO_CUT) => Ok(None), Err(s) => Err(error(s)), } @@ -30,9 +34,12 @@ impl Brep { /// leaves both parts the whole body, with no face from the tool (tag `self` to tell); `None` /// when a side is left empty. pub fn split_body(&self, tool: &Surface) -> Result> { + if route::backend() == route::Backend::Solidcore { + return core::split::split_body(self.core()?, tool.spec()).map(|r| r.map(|(b, f)| (Brep::made_core(b), Brep::made_core(f)))); + } init()?; - match ffi::split::split_body(&self.0, tool.handle()) { - Ok((b, f)) => Ok(Some((Brep(b), Brep(f)))), + match ffi::split::split_body(self.cpp()?, tool.handle()?) { + Ok((b, f)) => Ok(Some((Brep::made(b), Brep::made(f)))), Err(ffi::ops::ERR_NO_CUT) => Ok(None), Err(s) => Err(error(s)), } @@ -44,10 +51,14 @@ impl Brep { /// normal and the part in front. A `sheet` that is no sheet, or a `face` it does not have, is /// [`Error::Argument`]. pub fn split_body_by_sheet(&self, sheet: &Brep, face: usize) -> Result> { + if route::backend() == route::Backend::Solidcore { + return core::split::split_body_by_sheet(self.core()?, sheet.core()?, face) + .map(|r| r.map(|(b, f)| (Brep::made_core(b), Brep::made_core(f)))); + } init()?; let face = i64::try_from(face).map_err(|_| Error::Argument)?; - match ffi::split::split_body_by_sheet(&self.0, &sheet.0, face) { - Ok((b, f)) => Ok(Some((Brep(b), Brep(f)))), + match ffi::split::split_body_by_sheet(self.cpp()?, sheet.cpp()?, face) { + Ok((b, f)) => Ok(Some((Brep::made(b), Brep::made(f)))), Err(ffi::ops::ERR_NO_CUT) => Ok(None), Err(s) => Err(error(s)), } diff --git a/crates/smlib/src/ssi.rs b/crates/smlib/src/ssi.rs index 96fae8477a..040a244e38 100644 --- a/crates/smlib/src/ssi.rs +++ b/crates/smlib/src/ssi.rs @@ -8,10 +8,30 @@ use solidcraft_geom::brep as b; -use crate::{Error, Result, error, ffi, init}; +use std::sync::OnceLock; -/// A surface with its UV domain, owned on the C side. -pub struct Surface(ffi::ssi::SurfaceHandle); +use crate::{Error, Result, error, ffi, init, route}; + +/// A surface with its UV domain: the description it was made from, from which solidcore makes its +/// own ([`crate::core::split`]), and the C side's, made when the C++ kernel needs it. +pub struct Surface { + handle: OnceLock>, + /// solidcore's, made when it is first needed. + core: OnceLock>, + spec: Spec, +} + +/// What a [`Surface`] was made from: its constructor's arguments, checked as the C API checks them +/// ([`Spec::check`]). +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Spec { + Plane { origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], u: [f64; 2], v: [f64; 2] }, + Cylinder { origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, angle_deg: [f64; 2], height: [f64; 2] }, + Cone { apex: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], half_angle_deg: f64, height: [f64; 2] }, + Sphere { center: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, lon_deg: [f64; 2], lat_deg: [f64; 2] }, + Torus { center: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], major_radius: f64, minor_radius: f64, major_deg: [f64; 2], minor_deg: [f64; 2] }, + Nurbs { degree: [usize; 2], nu: usize, nv: usize, knots_u: Vec, knots_v: Vec, points: Vec<[f64; 3]>, weights: Option> }, +} impl std::fmt::Debug for Surface { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -23,35 +43,166 @@ fn int(n: usize) -> Result { i32::try_from(n).map_err(|_| Error::Argument) } +/// The largest coordinate, degree and control net the C API takes (`SMLIB_SURFACE_MAX_*`). +const MAX_COORD: f64 = 1e8; +const MAX_DEGREE: usize = 25; +const MAX_POLES: usize = 1_000_000; + +fn bounded(v: &[f64]) -> bool { + v.iter().all(|x| x.is_finite() && x.abs() <= MAX_COORD) +} + +/// Two non-zero axes that are not (nearly) parallel. +fn axes(x: &[f64; 3], y: &[f64; 3]) -> bool { + let len = |a: &[f64; 3]| (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt(); + let c = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]]; + bounded(x) && bounded(y) && len(x) > 1e-12 && len(y) > 1e-12 && len(&c) > 1e-9 * len(x) * len(y) +} + +/// An increasing interval inside `[lo, hi]` spanning at most `span`. +fn range(r: &[f64; 2], lo: f64, hi: f64, span: f64) -> bool { + bounded(r) && r[0] < r[1] && r[0] >= lo && r[1] <= hi && r[1] - r[0] <= span +} + +fn length(x: f64) -> bool { + x.is_finite() && x > 1e-9 && x <= MAX_COORD +} + +/// A knot vector of n + p + 1 non-decreasing values with a non-empty domain `[k[p], k[n]]`, no +/// interior knot repeated more than p times and no end knot more than p + 1. +fn knots(k: &[f64], n: usize, p: usize) -> bool { + if k.len() != n + p + 1 || !bounded(k) || k.windows(2).any(|w| w[1] < w[0]) { + return false; + } + let (Some(lo), Some(hi)) = (k.get(p).copied(), k.get(n).copied()) else { return false }; + let mut run = 1; + for w in k.windows(2) { + run = if w[1] == w[0] { run + 1 } else { 1 }; + if run > p + 1 || (run > p && w[1] > lo && w[1] < hi) { + return false; + } + } + lo < hi +} + +impl Spec { + /// The checks each of the C API's constructors makes of its arguments, so that a surface is + /// refused alike on either kernel, and without the C++ kernel. + fn check(&self) -> Result<()> { + let whole = |r: &[f64; 2]| range(r, -MAX_COORD, MAX_COORD, 2.0 * MAX_COORD); + let turn = |r: &[f64; 2]| range(r, -360.0, 360.0, 360.0); + let ok = match self { + Spec::Plane { origin, xaxis, yaxis, u, v } => bounded(origin) && axes(xaxis, yaxis) && whole(u) && whole(v), + Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => { + bounded(origin) && axes(xaxis, yaxis) && length(*radius) && turn(angle_deg) && range(height, -MAX_COORD, MAX_COORD, MAX_COORD) + } + Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => { + bounded(apex) + && axes(xaxis, yaxis) + && (0.01..=89.0).contains(half_angle_deg) + && range(height, 0.0, MAX_COORD, MAX_COORD) + && height[1] - height[0] > 1e-9 + } + Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => { + bounded(center) && axes(xaxis, yaxis) && length(*radius) && turn(lon_deg) && range(lat_deg, -90.0, 90.0, 180.0) + } + Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => { + bounded(center) + && axes(xaxis, yaxis) + && length(*major_radius) + && length(*minor_radius) + && minor_radius < major_radius + && turn(major_deg) + && turn(minor_deg) + } + Spec::Nurbs { degree: [p, q], nu, nv, knots_u, knots_v, points, weights } => { + (1..=MAX_DEGREE).contains(p) + && (1..=MAX_DEGREE).contains(q) + && *nu > *p + && *nv > *q + && nu.checked_mul(*nv).is_some_and(|n| n <= MAX_POLES && points.len() == n) + && knots(knots_u, *nu, *p) + && knots(knots_v, *nv, *q) + && points.iter().all(|c| bounded(c)) + && weights.as_ref().is_none_or(|w| w.len() == points.len() && w.iter().all(|x| (1e-6..=1e6).contains(x))) + } + }; + if ok { Ok(()) } else { Err(Error::Argument) } + } + + /// The C side's surface. + fn on_cpp(&self) -> Result { + init()?; + match self { + Spec::Plane { origin, xaxis, yaxis, u, v } => ffi::ssi::plane(origin, xaxis, yaxis, u, v), + Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height } => { + ffi::ssi::cylinder(origin, xaxis, yaxis, *radius, angle_deg, height) + } + Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height } => ffi::ssi::cone(apex, xaxis, yaxis, *half_angle_deg, height), + Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg } => ffi::ssi::sphere(center, xaxis, yaxis, *radius, lon_deg, lat_deg), + Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg } => { + ffi::ssi::torus(center, xaxis, yaxis, *major_radius, *minor_radius, major_deg, minor_deg) + } + Spec::Nurbs { degree, nu, nv, knots_u, knots_v, points, weights } => { + let flat: Vec = points.iter().flatten().copied().collect(); + ffi::ssi::nurbs([int(degree[0])?, int(degree[1])?], int(*nu)?, int(*nv)?, knots_u, knots_v, &flat, weights.as_deref()) + } + } + .map_err(error) + } +} + +/// Whether intersections run on the C++ kernel ([`route::backend`]). +fn ssi_on_cpp() -> bool { + route::backend() == route::Backend::Smlib +} + impl Surface { - pub(crate) fn handle(&self) -> &ffi::ssi::SurfaceHandle { - &self.0 + /// The C side's surface, made the first time the C++ kernel needs it. + pub(crate) fn handle(&self) -> Result<&ffi::ssi::SurfaceHandle> { + self.handle.get_or_init(|| self.spec.on_cpp()).as_ref().map_err(Clone::clone) + } + + /// solidcore's surface, made the first time it is needed. + fn core(&self) -> Result<&crate::core::split::Tool> { + self.core.get_or_init(|| crate::core::ssi::surface(&self.spec)).as_ref().map_err(Clone::clone) + } + + pub(crate) fn spec(&self) -> &Spec { + &self.spec + } + + /// A surface from its checked description. On the C++ + /// kernel, its side is made now, so that what it refuses is refused here. + fn made(spec: Spec) -> Result { + spec.check()?; + let s = Surface { handle: OnceLock::new(), core: OnceLock::new(), spec }; + if ssi_on_cpp() { + s.handle()?; + } + Ok(s) } /// `origin + u*x + v*y` (x, y the axes made unit), for u in `u`, v in `v`. pub fn plane(origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], u: [f64; 2], v: [f64; 2]) -> Result { - init()?; - ffi::ssi::plane(&origin, &xaxis, &yaxis, &u, &v).map(Surface).map_err(error) + Surface::made(Spec::Plane { origin, xaxis, yaxis, u, v }) } /// A cylinder of `radius` about the axis through `origin` along `xaxis × yaxis`, angle /// `angle_deg` from `xaxis` towards `yaxis`, for heights `height` measured from `origin`. pub fn cylinder(origin: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, angle_deg: [f64; 2], height: [f64; 2]) -> Result { - init()?; - ffi::ssi::cylinder(&origin, &xaxis, &yaxis, radius, &angle_deg, &height).map(Surface).map_err(error) + Surface::made(Spec::Cylinder { origin, xaxis, yaxis, radius, angle_deg, height }) } /// A full cone with its apex at `apex` along `xaxis × yaxis`, radius `h * tan(half_angle_deg)` /// at distance h along the axis, for h in `height` (from 0: through the apex). pub fn cone(apex: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], half_angle_deg: f64, height: [f64; 2]) -> Result { - init()?; - ffi::ssi::cone(&apex, &xaxis, &yaxis, half_angle_deg, &height).map(Surface).map_err(error) + Surface::made(Spec::Cone { apex, xaxis, yaxis, half_angle_deg, height }) } /// A sphere of `radius` at `center`, over longitudes `lon_deg` and latitudes `lat_deg`. pub fn sphere(center: [f64; 3], xaxis: [f64; 3], yaxis: [f64; 3], radius: f64, lon_deg: [f64; 2], lat_deg: [f64; 2]) -> Result { - init()?; - ffi::ssi::sphere(¢er, &xaxis, &yaxis, radius, &lon_deg, &lat_deg).map(Surface).map_err(error) + Surface::made(Spec::Sphere { center, xaxis, yaxis, radius, lon_deg, lat_deg }) } /// A ring torus at `center` about `xaxis × yaxis`, `major_deg` round the axis and `minor_deg` @@ -65,8 +216,7 @@ impl Surface { major_deg: [f64; 2], minor_deg: [f64; 2], ) -> Result { - init()?; - ffi::ssi::torus(¢er, &xaxis, &yaxis, major_radius, minor_radius, &major_deg, &minor_deg).map(Surface).map_err(error) + Surface::made(Spec::Torus { center, xaxis, yaxis, major_radius, minor_radius, major_deg, minor_deg }) } /// A (rational) B-spline surface: `nu × nv` control points, point (i, j) (i along u) at @@ -80,9 +230,15 @@ impl Surface { points: &[[f64; 3]], weights: Option<&[f64]>, ) -> Result { - init()?; - let flat: Vec = points.iter().flatten().copied().collect(); - ffi::ssi::nurbs([int(degree[0])?, int(degree[1])?], int(nu)?, int(nv)?, knots_u, knots_v, &flat, weights).map(Surface).map_err(error) + Surface::made(Spec::Nurbs { + degree, + nu, + nv, + knots_u: knots_u.to_vec(), + knots_v: knots_v.to_vec(), + points: points.to_vec(), + weights: weights.map(<[f64]>::to_vec), + }) } /// A schema 4 record surface over the parameter box `domain` (`[[u0, u1], [v0, v1]]`, the @@ -142,12 +298,18 @@ impl Surface { /// The UV domain the intersection runs within, as `[u0, u1, v0, v1]`. pub fn domain(&self) -> Result<[f64; 4]> { - ffi::ssi::domain(&self.0).map_err(error) + if !ssi_on_cpp() { + return self.core().map(crate::core::ssi::domain); + } + ffi::ssi::domain(self.handle()?).map_err(error) } /// The surface's point at (u, v). pub fn evaluate(&self, u: f64, v: f64) -> Result<[f64; 3]> { - ffi::ssi::evaluate(&self.0, u, v).map_err(error) + if !ssi_on_cpp() { + return crate::core::ssi::evaluate(self.core()?, u, v); + } + ffi::ssi::evaluate(self.handle()?, u, v).map_err(error) } } @@ -158,8 +320,6 @@ pub enum Method { Global, /// `SmAdvSurfaceIntersector`, the general NURBS path. Advanced, - /// `SmSurfaceIntersector`, the basic intersector. - Basic, } impl Method { @@ -167,7 +327,6 @@ impl Method { match self { Method::Global => 0, Method::Advanced => 1, - Method::Basic => 2, } } } @@ -244,7 +403,7 @@ pub struct SsiResult { } /// The 3D B-spline: polynomial where every weight is 1. -fn curve3(c: ffi::ssi::Nurbs) -> b::Curve { +pub(crate) fn curve3(c: ffi::ssi::Nurbs) -> b::Curve { let points = c.points.as_chunks::<3>().0; if c.weights.iter().all(|w| *w == 1.0) { b::Curve::Spline { knots: c.knots, points: points.to_vec() } @@ -257,7 +416,7 @@ fn curve3(c: ffi::ssi::Nurbs) -> b::Curve { } /// A UV B-spline, as [`curve3`]. -fn curve2(c: ffi::ssi::Nurbs) -> b::Curve2 { +pub(crate) fn curve2(c: ffi::ssi::Nurbs) -> b::Curve2 { let points = c.points.as_chunks::<2>().0; if c.weights.iter().all(|w| *w == 1.0) { b::Curve2::Spline { knots: c.knots, points: points.to_vec() } @@ -279,7 +438,10 @@ fn uv(r: &ffi::ssi::ResultHandle, i: i32, which: i32) -> Result Result { - let r = ffi::ssi::ssi(&a.0, &b.0, method.code(), approx_tol).map_err(error)?; + if !ssi_on_cpp() { + return crate::core::ssi::intersect(a.core()?, b.core()?, method, approx_tol); + } + let r = ffi::ssi::ssi(a.handle()?, b.handle()?, method.code(), approx_tol).map_err(error)?; let n = ffi::ssi::curve_count(&r).map_err(error)?; let mut curves = Vec::new(); for i in 0..n { @@ -309,6 +471,8 @@ mod tests { /// A plane through a sphere's centre: one great circle. #[test] fn plane_through_sphere_is_a_circle() { + #[cfg(feature = "native")] + let _cpp = route::on_cpp(); let z = [0.0, 0.0, 1.0]; let (x, y) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); let plane = Surface::plane([0.0; 3], x, y, [-2.0, 2.0], [-2.0, 2.0]); @@ -330,15 +494,52 @@ mod tests { } } + /// Refused here, before any kernel: alike on either, and with no C++ kernel. #[test] fn bad_input_is_an_argument_error() { - match Surface::plane([0.0; 3], [1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0], [0.0, 1.0]) { - Err(Error::NotBuilt) => {} - r => assert_eq!(r.err(), Some(Error::Argument)), + let (x, y) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]); + let refused = [ + Surface::plane([0.0; 3], x, x, [0.0, 1.0], [0.0, 1.0]), + Surface::plane([0.0; 3], x, y, [1.0, 0.0], [0.0, 1.0]), + Surface::plane([f64::NAN; 3], x, y, [0.0, 1.0], [0.0, 1.0]), + Surface::cylinder([0.0; 3], x, y, 0.0, [0.0, 360.0], [0.0, 1.0]), + Surface::cone([0.0; 3], x, y, f64::NAN, [0.0, 1.0]), + Surface::sphere([0.0; 3], x, y, 1.0, [0.0, 360.0], [-91.0, 90.0]), + Surface::torus([0.0; 3], x, y, 1.0, 2.0, [0.0, 360.0], [0.0, 360.0]), + Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 3], None), + Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 4], None), + Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 4], Some(&[0.0; 4])), + Surface::nurbs([0, 1], 2, 2, &[0.0, 0.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 4], None), + ]; + for (i, r) in refused.iter().enumerate() { + assert_eq!(r.as_ref().err(), Some(&Error::Argument), "{i}"); } - match Surface::nurbs([1, 1], 2, 2, &[0.0, 0.0, 1.0, 1.0], &[0.0, 0.0, 1.0, 1.0], &[[0.0; 3]; 3], None) { - Err(Error::NotBuilt) => {} - r => assert_eq!(r.err(), Some(Error::Argument)), + } + + /// On solidcore a surface needs no C++ kernel, and answers by itself. The basic intersector is + /// the one method solidcore has no public entry for, and it says so; nothing falls back. + #[test] + fn surfaces_on_solidcore_answer_without_the_cpp_kernel() { + if ssi_on_cpp() { + return; + } + let (x, y, z) = ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]); + let plane = Surface::plane([0.0; 3], x, y, [-2.0, 2.0], [-3.0, 3.0]).unwrap(); + assert_eq!(plane.domain().unwrap(), [-2.0, 2.0, -3.0, 3.0]); + assert_eq!(plane.evaluate(1.0, -2.0).unwrap(), [1.0, -2.0, 0.0]); + assert_eq!(plane.evaluate(f64::NAN, 0.0).unwrap_err(), Error::Argument); + let sphere = Surface::sphere([0.0; 3], x, z, 1.0, [0.0, 360.0], [-90.0, 90.0]).unwrap(); + for method in [Method::Global, Method::Advanced] { + let r = intersect(&plane, &sphere, method, 1e-6).unwrap(); + assert!(!r.curves.is_empty(), "{method:?}"); + for c in &r.curves { + let eval = b::eval::CurveEval::new(&c.curve).unwrap(); + let [t0, t1] = c.interval; + for k in 0..=100 { + let p = eval.point(t0 + (t1 - t0) * f64::from(k) / 100.0).unwrap(); + assert!((p.x.hypot(p.y) - 1.0).abs() < 1e-5 && p.z.abs() < 1e-5, "{method:?}: {p:?}"); + } + } } } } diff --git a/crates/smlib/src/sweeps.rs b/crates/smlib/src/sweeps.rs index e13055242e..c2c040034b 100644 --- a/crates/smlib/src/sweeps.rs +++ b/crates/smlib/src/sweeps.rs @@ -6,7 +6,7 @@ //! With `cap`, the profile's loops close the ends and the result must be a closed manifold solid //! ([`Error::NotSolid`] otherwise); without, it is the swept sheet. -use crate::{Brep, Error, Result, error, ffi, init}; +use crate::{Brep, Error, Result, core, error, ffi, init, route}; /// A 3D curve of a profile, path or section. #[derive(Clone, Debug, PartialEq)] @@ -99,13 +99,13 @@ pub const MAX_CURVES: usize = ffi::sweeps::CURVE_MAX_CURVES; /// The curves as the C API's curve list. #[derive(Default)] -struct List { +pub(crate) struct List { kinds: Vec, reals: Vec, } impl List { - fn of<'a>(curves: impl IntoIterator) -> Result { + pub(crate) fn of<'a>(curves: impl IntoIterator) -> Result { let mut l = List::default(); for c in curves { l.push(c)?; @@ -150,18 +150,36 @@ impl List { Ok(()) } - fn raw(&self) -> ffi::sweeps::List<'_> { + pub(crate) fn raw(&self) -> ffi::sweeps::List<'_> { ffi::sweeps::List::new(&self.kinds, &self.reals) } } +#[cfg(test)] +thread_local! { + /// Tests: this thread's sweeps run on the C++ kernel whatever the choice. + pub(crate) static ON_CPP: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether the sweeps run on solidcore ([`route::backend`]). +fn on_core() -> bool { + #[cfg(test)] + if ON_CPP.with(std::cell::Cell::get) { + return false; + } + route::backend() == route::Backend::Solidcore +} + impl Brep { /// A sheet of planar faces bounded by `loops`: closed loops of curves in one plane, in any /// order, nested as outer loops, their holes and islands in the holes. pub fn planar_faces(loops: &[Curve]) -> Result { + if on_core() { + return core::sweep::planar_faces(loops).map(Brep::made_core); + } init()?; let l = List::of(loops)?; - ffi::sweeps::planar_faces(&l.raw()).map(Brep).map_err(error) + ffi::sweeps::planar_faces(&l.raw()).map(Brep::made).map_err(error) } /// The planar `profile` extruded `distance` along the unit `direction` (square to its plane). @@ -172,6 +190,9 @@ impl Brep { /// [`DraftCorner::Curvature`], a curve of a profile with a sharp corner is neither a line nor /// an arc). pub fn extrude(profile: &[Curve], direction: [f64; 3], distance: f64, draft: f64, corner: DraftCorner, cap: bool) -> Result { + if on_core() { + return core::sweep::extrude(profile, direction, distance, draft, corner, cap).map(Brep::made_core); + } init()?; let l = List::of(profile)?; let rule = match corner { @@ -180,7 +201,7 @@ impl Brep { DraftCorner::Round => ffi::sweeps::DRAFT_CORNER_ROUND, }; let mut at = [f64::NAN; 3]; - ffi::sweeps::extrude(&l.raw(), &direction, distance, draft, rule, cap, &mut at).map(Brep).map_err(|s| { + ffi::sweeps::extrude(&l.raw(), &direction, distance, draft, rule, cap, &mut at).map(Brep::made).map_err(|s| { let why = match s { ffi::sweeps::ERR_CORNER_APART => Some(CornerRefusal::Apart), ffi::sweeps::ERR_WALL_CLOSES => Some(CornerRefusal::WallCloses), @@ -197,22 +218,28 @@ impl Brep { /// The planar `profile` turned `angle` radians (0 < angle <= 2 pi) right-handed about the /// axis through `origin` along `direction`, in the profile's plane (touching it at most). pub fn revolve(profile: &[Curve], origin: [f64; 3], direction: [f64; 3], angle: f64, cap: bool) -> Result { + if on_core() { + return core::sweep::revolve(profile, origin, direction, angle, cap).map(Brep::made_core); + } init()?; let l = List::of(profile)?; - ffi::sweeps::revolve(&l.raw(), &origin, &direction, angle, cap).map(Brep).map_err(error) + ffi::sweeps::revolve(&l.raw(), &origin, &direction, angle, cap).map(Brep::made).map_err(error) } /// `profile` swept along `path` (end-to-end curves), carried with the path's start: the /// profile keeps its place relative to it. The walls are B-spline approximations, one per /// stretch of profile between tangent breaks. pub fn sweep(profile: &[Curve], path: &[Curve], frame: SweepFrame, cap: bool) -> Result { + if on_core() { + return core::sweep::sweep_placed(profile, path, frame, cap).map(|(b, _)| Brep::made_core(b)); + } init()?; let (l, p) = (List::of(profile)?, List::of(path)?); let frame = match frame { SweepFrame::Follow => ffi::sweeps::SWEEP_FOLLOW, SweepFrame::Translate => ffi::sweeps::SWEEP_TRANSLATE, }; - ffi::sweeps::sweep(&l.raw(), &p.raw(), frame, cap).map(Brep).map_err(error) + ffi::sweeps::sweep(&l.raw(), &p.raw(), frame, cap).map(Brep::made).map_err(error) } /// [`Brep::sweep`], with where the sweep placed the profile: the rigid motions carrying the @@ -221,6 +248,9 @@ impl Brep { /// out from point to point along the path); a sweep that carries on from where this one left /// the profile starts from the last. pub fn sweep_placed(profile: &[Curve], path: &[Curve], frame: SweepFrame, cap: bool) -> Result<(Brep, [Affine; 3])> { + if on_core() { + return core::sweep::sweep_placed(profile, path, frame, cap).map(|(b, m)| (Brep::made_core(b), m)); + } init()?; let (l, p) = (List::of(profile)?, List::of(path)?); let frame = match frame { @@ -228,7 +258,7 @@ impl Brep { SweepFrame::Translate => ffi::sweeps::SWEEP_TRANSLATE, }; let mut placed = [0.0; 36]; - let brep = ffi::sweeps::sweep_placed(&l.raw(), &p.raw(), frame, cap, &mut placed).map(Brep).map_err(error)?; + let brep = ffi::sweeps::sweep_placed(&l.raw(), &p.raw(), frame, cap, &mut placed).map(Brep::made).map_err(error)?; let motion = |i: usize| { let at = |k: usize| placed.get(12 * i + k).copied().unwrap_or(0.0); Affine { m: [[at(0), at(1), at(2)], [at(3), at(4), at(5)], [at(6), at(7), at(8)]], t: [at(9), at(10), at(11)] } @@ -249,9 +279,12 @@ impl Brep { direction: [f64; 3], tolerance: f64, ) -> Result> { + if on_core() { + return core::sweep::project_curves(self.core()?, face_point, pick_tolerance, curves, direction, tolerance); + } init()?; let l = List::of(curves)?; - let out = ffi::sweeps::project_curves(&self.0, &face_point, pick_tolerance, &l.raw(), &direction, tolerance).map_err(picked)?; + let out = ffi::sweeps::project_curves(self.cpp()?, &face_point, pick_tolerance, &l.raw(), &direction, tolerance).map_err(picked)?; nurbs_list(&out) } @@ -265,14 +298,20 @@ impl Brep { plane_normal: [f64; 3], tolerance: f64, ) -> Result> { + if on_core() { + return core::sweep::face_section(self.core()?, face_point, pick_tolerance, plane_point, plane_normal, tolerance); + } init()?; - let out = ffi::sweeps::face_section(&self.0, &face_point, pick_tolerance, &plane_point, &plane_normal, tolerance).map_err(picked)?; + let out = ffi::sweeps::face_section(self.cpp()?, &face_point, pick_tolerance, &plane_point, &plane_normal, tolerance).map_err(picked)?; nurbs_list(&out) } /// A loft (skin) through `sections` (at least two closed loops), `degree` (1 ruled, 2 or 3) /// across them. pub fn loft(sections: &[Vec], degree: u32, cap: bool) -> Result { + if on_core() { + return core::sweep::loft(sections, degree, cap).map(Brep::made_core); + } init()?; if sections.len() < 2 || sections.len() > MAX_CURVES || !(1..=3).contains(°ree) { return Err(Error::Argument); @@ -280,13 +319,16 @@ impl Brep { let l = List::of(sections.iter().flatten())?; let sizes: Vec = sections.iter().map(|s| i64::try_from(s.len()).map_err(|_| Error::Argument)).collect::>()?; let degree = i32::try_from(degree).map_err(|_| Error::Argument)?; - ffi::sweeps::loft(&l.raw(), &sizes, degree, cap).map(Brep).map_err(error) + ffi::sweeps::loft(&l.raw(), &sizes, degree, cap).map(Brep::made).map_err(error) } /// The planar `profile` placed by each of `stations` in turn (2…[`MAX_STATIONS`], each /// section exactly the profile's image), its walls skinned through the sections, cubic /// across them: one wall per profile curve. `ends` says how the ends close. pub fn sweep_stations(profile: &[Curve], stations: &[Affine], ends: Ends) -> Result { + if on_core() { + return core::sweep::sweep_stations(profile, stations, ends).map(Brep::made_core); + } init()?; if !(2..=MAX_STATIONS).contains(&stations.len()) || profile.len().saturating_mul(stations.len()) > MAX_STATION_CURVES { return Err(Error::Argument); @@ -298,24 +340,30 @@ impl Brep { Ends::Capped => ffi::sweeps::ENDS_CAPPED, Ends::Joined => ffi::sweeps::ENDS_JOINED, }; - ffi::sweeps::sweep_stations(&l.raw(), &maps, ends).map(Brep).map_err(error) + ffi::sweeps::sweep_stations(&l.raw(), &maps, ends).map(Brep::made).map_err(error) } /// A loft from the planar `section` (one closed loop of lines, in order) to the point `apex` /// off its plane: a pyramid, its base the section and a triangle on each line. pub fn loft_to_point(section: &[Curve], apex: [f64; 3]) -> Result { + if on_core() { + return core::sweep::loft_to_point(section, apex).map(Brep::made_core); + } init()?; let l = List::of(section)?; - ffi::sweeps::loft_to_point(&l.raw(), &apex).map(Brep).map_err(error) + ffi::sweeps::loft_to_point(&l.raw(), &apex).map(Brep::made).map_err(error) } /// `profile` carried by a screw motion about the axis through `origin` along `direction`: /// `turns` right-handed turns about it, rising `pitch` (signed) per turn, capped at both ends, /// its walls within about `tolerance` of the true screw surfaces. pub fn screw_sweep(profile: &[Curve], origin: [f64; 3], direction: [f64; 3], pitch: f64, turns: f64, tolerance: f64) -> Result { + if on_core() { + return core::sweep::screw_sweep(profile, origin, direction, pitch, turns, None, tolerance).map(Brep::made_core); + } init()?; let l = List::of(profile)?; - ffi::sweeps::screw_sweep(&l.raw(), &origin, &direction, pitch, turns, tolerance).map(Brep).map_err(error) + ffi::sweeps::screw_sweep(&l.raw(), &origin, &direction, pitch, turns, tolerance).map(Brep::made).map_err(error) } /// [`screw_sweep`](Self::screw_sweep) with the profile moved off the axis as it turns: at turn @@ -333,10 +381,13 @@ impl Brep { law: &[(f64, f64)], tolerance: f64, ) -> Result { + if on_core() { + return core::sweep::screw_sweep(profile, origin, direction, pitch, turns, Some(law), tolerance).map(Brep::made_core); + } init()?; let l = List::of(profile)?; let flat: Vec = law.iter().flat_map(|(t, o)| [*t, *o]).collect(); - ffi::sweeps::screw_sweep_radial(&l.raw(), &origin, &direction, pitch, turns, &flat, tolerance).map(Brep).map_err(error) + ffi::sweeps::screw_sweep_radial(&l.raw(), &origin, &direction, pitch, turns, &flat, tolerance).map(Brep::made).map_err(error) } } diff --git a/crates/smlib/src/tests.rs b/crates/smlib/src/tests.rs index 5125cc58e9..41ac8e13e7 100644 --- a/crates/smlib/src/tests.rs +++ b/crates/smlib/src/tests.rs @@ -9,7 +9,8 @@ fn box_end_to_end() { let c = b.try_clone().unwrap(); drop(b); assert!((c.volume(1e-7).unwrap() - 6000.0).abs() < 1e-6); - assert_eq!(current_call(), None, "no call in flight between calls"); + let mut buf = [0u8; 512]; + assert_eq!(ffi::current_call(&mut buf), 0, "no C call in flight between calls"); } #[cfg(feature = "native")] @@ -17,13 +18,86 @@ fn box_end_to_end() { fn bad_arguments_are_errors() { assert_eq!(Brep::make_box([0.0; 3], [1.0, -1.0, 1.0]).unwrap_err(), Error::Argument); assert_eq!(Brep::make_box([f64::NAN, 0.0, 0.0], [1.0; 3]).unwrap_err(), Error::Argument); + // each kernel refuses a bad box the same way, whichever one is in force + init().unwrap(); + for size in [[1.0, -1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, f64::NAN]] { + assert_eq!(core::make_box([0.0; 3], size).err(), Some(Error::Argument), "solidcore: {size:?}"); + assert_eq!(ffi::make_box(&[0.0; 3], &size).map_err(error).err(), Some(Error::Argument), "C++: {size:?}"); + } let b = Brep::make_box([0.0; 3], [1.0; 3]).unwrap(); assert_eq!(b.volume(f64::INFINITY).unwrap_err(), Error::Argument); } +/// `.smb` files on solidcore, in both forms and with a curved body, and a missing or corrupt file +/// is an error, not a panic. +#[test] +fn solidcore_reads_back_the_files_it_writes() { + use std::f64::consts::PI; + let dir = std::env::temp_dir().join(format!("solidcraft-smlib-core-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let cylinder = core::make_cylinder([1.0, 2.0, 3.0], 2.0, 5.0).map(Brep::made_core).unwrap(); + for ascii in [false, true] { + let path = dir.join(format!("cylinder-{ascii}.smb")); + core::file::write(cylinder.core().unwrap(), &path, ascii).unwrap(); + let back = core::file::read(&path, ascii).map(Brep::made_core).unwrap(); + let v = core::volume(back.core().unwrap(), 1e-7).unwrap(); + assert!((v - PI * 4.0 * 5.0).abs() < 1e-5, "ascii {ascii}: {v}"); + assert_eq!(core::counts(back.core().unwrap()).unwrap(), core::counts(cylinder.core().unwrap()).unwrap(), "ascii {ascii}"); + } + let body = cylinder.core().unwrap(); + assert!(core::file::write(body, &dir.join("missing/x.smb"), false).is_err()); + assert!(core::file::read(&dir.join("missing.smb"), false).is_err()); + let junk = dir.join("junk.smb"); + std::fs::write(&junk, b"not a B-rep file").unwrap(); + assert!(core::file::read(&junk, false).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// ASCII that is no B-rep file is refused like binary. It comes back as an empty body today: +/// default-solidcore/bugs/ascii-junk-reads-as-empty.md. +#[test] +#[ignore = "solidcore: an ASCII file of junk reads as an empty B-rep instead of an error"] +fn solidcore_refuses_an_ascii_file_of_junk() { + let dir = std::env::temp_dir().join(format!("solidcraft-smlib-core-junk-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let junk = dir.join("junk.smb"); + std::fs::write(&junk, b"not a B-rep file").unwrap(); + let r = core::file::read(&junk, true); + let _ = std::fs::remove_dir_all(&dir); + assert!(r.is_err()); +} + +/// A file either kernel wrote reads on the other, with the same measure. +#[cfg(feature = "native")] +#[test] +fn each_kernel_reads_the_files_of_the_other() { + use std::f64::consts::PI; + let dir = std::env::temp_dir().join(format!("solidcraft-smlib-cross-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let want = PI * 4.0 * 5.0; + for ascii in [false, true] { + let from_core = dir.join(format!("core-{ascii}.smb")); + let body = core::make_cylinder([0.0; 3], 2.0, 5.0).unwrap(); + core::file::write(&body, &from_core, ascii).unwrap(); + crate::init().unwrap(); + let on_cpp = ffi::read(&c_path(&from_core).unwrap(), ascii).map(Brep::made).unwrap(); + let v = ffi::volume(on_cpp.cpp().unwrap(), 1e-7).unwrap(); + assert!((v - want).abs() < 1e-5, "C++ on solidcore's file, ascii {ascii}: {v}"); + let from_cpp = dir.join(format!("cpp-{ascii}.smb")); + let cpp = ffi::make_cylinder(&[0.0; 3], 2.0, 5.0).map(Brep::made).unwrap(); + crate::check(ffi::write(cpp.cpp().unwrap(), &c_path(&from_cpp).unwrap(), ascii)).unwrap(); + let on_core = core::file::read(&from_cpp, ascii).unwrap(); + let v = core::volume(&on_core, 1e-7).unwrap(); + assert!((v - want).abs() < 1e-5, "solidcore on C++'s file, ascii {ascii}: {v}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[cfg(feature = "native")] #[test] fn native_files_round_trip_and_failures_dump_their_inputs() { + let _dumps = DUMP_DIR_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _cpp = route::on_cpp(); let dir = std::env::temp_dir().join(format!("solidcraft-smlib-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let b = Brep::make_box([0.0; 3], [2.0, 3.0, 4.0]).unwrap(); @@ -65,8 +139,265 @@ fn calls_from_many_threads_are_serialised() { #[test] fn without_native_every_call_says_so() { assert_eq!(init(), Err(Error::NotBuilt)); - assert_eq!(Brep::make_box([0.0; 3], [1.0; 3]).unwrap_err(), Error::NotBuilt); - assert_eq!(current_call(), None); + // the C API's own box, whichever kernel is in force + assert_eq!(ffi::make_box(&[0.0; 3], &[1.0; 3]).map_err(error).err(), Some(Error::NotBuilt)); + // solidcore builds a box without SMLib; asking the C++ kernel about it says there is none + let b = core::make_box([0.0; 3], [1.0; 3]).map(Brep::made_core).unwrap(); + assert_eq!(b.cpp().err(), Some(Error::NotBuilt)); + // and says how to get it + assert!(!built()); + assert!(Error::NotBuilt.to_string().contains("--features smlib"), "{}", Error::NotBuilt); +} + +/// The dump folder is process-wide: the tests that set it take turns. +static DUMP_DIR_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Which public entry points work with no C++ kernel: all of them but [`CPP_ONLY`], which are the +/// C++ kernel's own arrays and start-up, reached only across kernels and by tests. A new entry point +/// fails [`every_entry_point_is_in_the_guard`] until it is called in the test below or listed. +const CPP_ONLY: [&str; 8] = ["init", "new", "get_i64", "get_f64", "set_i64", "set_f64", "import", "export"]; + +/// Entry points that ask no kernel, so have nothing to say here. +const NO_KERNEL: [&str; 11] = [ + "inertia_tensor", + "is_valid", + "failed_tests", + "native", + "arrays_keeping_splines", + "from_arrays", + "from_record_keeping_splines", + "to_native_report", + "to_native_ordered", + "blends", + // The last refusal's fit, read after it: on solidcore, none yet. + "blend_fit", +]; + +const ANSWERING: [&str; 62] = [ + "set_dump_dir", + "current_call", + "make_box", + "read", + "write", + "try_clone", + "transformed", + "volume", + "counts", + "boolean", + "union_all", + "boolean2d", + "imprint", + "solid_count", + "fillet", + "chamfer", + "blend", + "blend_dropping", + "blend_setback", + "blend_setback_open", + "fillet_variable", + "chamfer_sides", + "shell", + "offset_faces", + "thicken", + "merge_faces", + "draft", + "split_faces", + "split_body", + "split_body_by_sheet", + "untrim_face", + "extend_sheet", + "face_points", + "stitch", + "offset_sheet", + "classify_point", + "mass_properties", + "bounds", + "edge_face_distance", + "area", + "is_manifold_solid", + "tagged", + "history", + "check", + "make_cylinder", + "make_sphere", + "make_cone", + "make_torus", + "arrays", + "to_native", + "planar_faces", + "extrude", + "revolve", + "sweep", + "sweep_placed", + "project_curves", + "face_section", + "loft", + "sweep_stations", + "loft_to_point", + "screw_sweep", + "screw_sweep_radial", +]; + +/// Every `pub fn` of the files that hold the crate's kernel entry points is accounted for. +#[test] +fn every_entry_point_is_in_the_guard() { + let files = [ + include_str!("lib.rs"), + include_str!("ops.rs"), + include_str!("split.rs"), + include_str!("trim.rs"), + include_str!("sheets.rs"), + include_str!("measure.rs"), + include_str!("flat.rs"), + include_str!("history.rs"), + include_str!("check.rs"), + include_str!("sweeps.rs"), + include_str!("export.rs"), + ]; + for file in files { + for line in file.lines() { + let Some(rest) = line.trim_start().strip_prefix("pub fn ") else { continue }; + let name = rest.split(['(', '<']).next().unwrap_or(rest); + assert!(ANSWERING.contains(&name) || CPP_ONLY.contains(&name) || NO_KERNEL.contains(&name), "`{name}` is in neither list"); + } + } +} + +/// With no C++ kernel, each entry point answers with something other than "not built": its result +/// or its own refusal, on a body of the right kind or the wrong one. Built with the C++ kernel, the +/// same calls with nothing chosen never touch a C++ body: nothing on the default path reaches it. +#[test] +fn without_the_cpp_kernel_every_entry_point_answers() { + use crate::sweeps::{Affine, Curve, DraftCorner, Ends, SweepFrame}; + use crate::trim::{Continuation, Holes}; + use crate::{Blend, BooleanOp, ChamferSecond, CheckLevel}; + if route::backend() != route::Backend::Solidcore { + return; + } + fn says(name: &str, r: Result) -> Option { + match r { + Err(Error::NotBuilt) => panic!("{name} needs the C++ kernel"), + r => r.ok(), + } + } + let dir = std::env::temp_dir().join(format!("solidcraft-smlib-guard-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + { + let _dumps = DUMP_DIR_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + says("set_dump_dir", set_dump_dir(None)); + } + let _ = current_call(); + route::take_reached(); + let a = says("make_box", Brep::make_box([0.0; 3], [10.0; 3])).unwrap(); + let t = says("make_box", Brep::make_box([5.0; 3], [10.0; 3])).unwrap(); + let p = |x: f64, y: f64, z: f64| [x, y, z]; + let edge = [p(5.0, 10.0, 10.0)]; + let top = [p(5.0, 5.0, 10.0)]; + let path = dir.join("a.smb"); + says("write", a.write(&path, false)); + says("read", Brep::read(&path, false)); + says("try_clone", a.try_clone()); + says("transformed", a.transformed(&[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]])); + says("volume", a.volume(1e-6)); + says("counts", a.counts()); + says("boolean", a.boolean(&t, BooleanOp::Union)); + says("union_all", Brep::union_all(&[&a, &t])); + says("boolean2d", a.boolean2d(&t, BooleanOp::Union)); + says("imprint", a.imprint(&t)); + says("solid_count", a.solid_count()); + says("fillet", a.fillet(&edge, 0.1, 1.0)); + says("chamfer", a.chamfer(&edge, 0.1, 1.0)); + says("blend", a.blend(Blend::Round, &edge, &[1.0], 0.1)); + says("blend_dropping", a.blend_dropping(Blend::Round, &edge, &[1.0], 0.1)); + let corner = [p(10.0, 10.0, 10.0)]; + let three = [p(5.0, 10.0, 10.0), p(10.0, 5.0, 10.0), p(10.0, 10.0, 5.0)]; + says("blend_setback", a.blend_setback(Blend::Round, &three, &[1.0; 3], &corner, &[2.0], 0.1)); + says("blend_setback_open", a.blend_setback_open(Blend::Round, &three, &[1.0; 3], &corner, &[2.0], 0.1)); + says("fillet_variable", a.fillet_variable(edge[0], 0.1, p(0.0, 10.0, 10.0), 1, &[0.0, 0.0, 1.0, 1.0], &[2.0, 1.0])); + says("chamfer_sides", a.chamfer_sides(&edge, 0.1, 1.0, ChamferSecond::Distance(2.0), p(0.0, 0.0, 1.0))); + says("shell", a.shell(&top, 0.1, 1.0)); + says("offset_faces", a.offset_faces(&top, 0.1, 1.0)); + says("thicken", a.thicken(1.0, false)); + says("merge_faces", a.merge_faces(&top, 0.1)); + says("draft", a.draft(&[p(5.0, 10.0, 5.0)], 0.1, [0.0; 3], p(0.0, 0.0, 1.0), p(0.0, 0.0, 1.0), 0.1)); + let tool = + says("Surface::plane", ssi::Surface::plane(p(0.0, 0.0, 5.0), p(1.0, 0.0, 0.0), p(0.0, 1.0, 0.0), [-50.0, 50.0], [-50.0, 50.0])).unwrap(); + says("split_faces", a.split_faces(&[0], &[&tool])); + says("split_body", a.split_body(&tool)); + let square = [p(0.0, 0.0, 5.0), p(10.0, 0.0, 5.0), p(10.0, 10.0, 5.0), p(0.0, 10.0, 5.0)]; + let lines: Vec = (0..4).map(|i| Curve::Line { start: square[i], end: square[(i + 1) % 4] }).collect(); + let sheet = says("planar_faces", Brep::planar_faces(&lines)).unwrap(); + says("split_body_by_sheet", a.split_body_by_sheet(&sheet, 0)); + says("untrim_face", sheet.untrim_face(p(5.0, 5.0, 5.0), 0.1, Holes::All, true, false)); + says("extend_sheet", sheet.extend_sheet(p(5.0, 5.0, 5.0), 0.1, 1.0, Continuation::Natural)); + says("face_points", a.face_points()); + says("stitch", sheet.stitch(0.1)); + says("offset_sheet", sheet.offset_sheet(1.0)); + says("classify_point", a.classify_point([1.0; 3])); + says("mass_properties", a.mass_properties(1e-6)); + says("bounds", a.bounds()); + says("edge_face_distance", a.edge_face_distance(0, 0)); + says("area", a.area(1e-6)); + says("is_manifold_solid", a.is_manifold_solid()); + says("history", says("tagged", a.tagged(0)).unwrap().history()); + says("check", a.check(CheckLevel::All)); + says("make_cylinder", Brep::make_cylinder([0.0; 3], 1.0, 1.0)); + says("make_sphere", Brep::make_sphere([0.0; 3], 1.0)); + says("make_cone", Brep::make_cone([0.0; 3], 2.0, 1.0, 1.0)); + says("make_torus", Brep::make_torus([0.0; 3], 2.0, 1.0)); + says("arrays", a.arrays()); + says("to_native", a.to_native().map_err(|_| Error::Argument)); + says("extrude", Brep::extrude(&lines, p(0.0, 0.0, 1.0), 1.0, 0.0, DraftCorner::Curvature, true)); + says("revolve", Brep::revolve(&lines, p(20.0, 0.0, 0.0), p(0.0, 1.0, 0.0), 1.0, true)); + let run = [Curve::Line { start: p(5.0, 5.0, 5.0), end: p(5.0, 5.0, 15.0) }]; + says("sweep", Brep::sweep(&lines, &run, SweepFrame::Follow, true)); + says("sweep_placed", Brep::sweep_placed(&lines, &run, SweepFrame::Follow, true)); + says("project_curves", a.project_curves(top[0], 0.1, &lines, p(0.0, 0.0, -1.0), 0.01)); + says("face_section", a.face_section(top[0], 0.1, top[0], p(1.0, 0.0, 0.0), 0.01)); + let lifted: Vec = (0..4) + .map(|i| Curve::Line { start: [square[i][0], square[i][1], 9.0], end: [square[(i + 1) % 4][0], square[(i + 1) % 4][1], 9.0] }) + .collect(); + says("loft", Brep::loft(&[lines.clone(), lifted], 1, true)); + let i3 = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + says("sweep_stations", Brep::sweep_stations(&lines, &[Affine { m: i3, t: [0.0; 3] }, Affine { m: i3, t: [0.0, 0.0, 2.0] }], Ends::Capped)); + says("loft_to_point", Brep::loft_to_point(&lines, p(5.0, 5.0, 12.0))); + says("screw_sweep", Brep::screw_sweep(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, 0.01)); + says("screw_sweep_radial", Brep::screw_sweep_radial(&lines, p(30.0, 0.0, 0.0), p(0.0, 0.0, 1.0), 12.0, 1.0, &[(0.0, 0.0), (1.0, 0.0)], 0.01)); + let (cpp, core) = route::take_reached(); + assert!(cpp == 0 && core > 0, "the default path touched a C++ body {cpp} times ({core} solidcore calls)"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A failing solidcore call leaves its inputs and what failed, and an answered one leaves nothing; +/// the call in flight is named, and gone after. A folder is process-wide, so this test is the one +/// that sets it. +#[test] +fn a_failing_solidcore_call_leaves_a_dump_and_names_itself() { + let _dumps = DUMP_DIR_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = std::env::temp_dir().join(format!("solidcraft-smlib-core-dump-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + core::dump::set_dir(Some(dir.clone())); + let a = core::make_box([0.0; 3], [10.0; 3]).unwrap(); + let seen: std::sync::Mutex> = std::sync::Mutex::new(None); + let r: Result<()> = core::with_body("smlib_test_failure", &a, |_, _| { + *seen.lock().unwrap() = core::dump::current(); + Err(solidcore::SolidError::invalid_input("test", "refused")) + }); + core::dump::set_dir(None); + assert!(r.is_err()); + assert_eq!(seen.lock().unwrap().as_deref(), Some("smlib_test_failure")); + { + // Under the kernel's lock no solidcore call is in flight. + let _kernel = core::KERNEL.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(core::dump::current(), None); + } + let got: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + let text = got.iter().find(|n| n.ends_with("-smlib_test_failure.txt")).unwrap_or_else(|| panic!("{got:?}")); + assert!(std::fs::read_to_string(dir.join(text)).unwrap().contains("refused")); + let smb = got.iter().find(|n| n.ends_with("-smlib_test_failure-brep.smb")).unwrap_or_else(|| panic!("{got:?}")); + let back = core::file::read(&dir.join(smb), false).unwrap(); + assert!((core::volume(&back, 1e-7).unwrap() - 1000.0).abs() < 1e-6); + let _ = std::fs::remove_dir_all(&dir); } #[test] @@ -82,9 +413,12 @@ fn call_lines_give_the_function_and_the_c_api_time() { #[cfg(feature = "native")] #[test] fn every_call_is_a_profiler_span_timed_by_the_c_api() { + let _cpp = route::on_cpp(); solidcraft_profile::set_enabled(true); let mark = solidcraft_profile::mark(); - let b = Brep::make_box([0.0; 3], [1.0; 3]).unwrap(); + // the C API's own box, whichever kernel is in force + crate::init().unwrap(); + let b = ffi::make_box(&[0.0; 3], &[1.0; 3]).map(Brep::made).unwrap(); let _ = b.volume(1e-7).unwrap(); let r = mark.report(); let names: Vec<&str> = r.rows.iter().filter(|r| r.category == "smlib").map(|r| r.name).collect(); diff --git a/crates/smlib/src/trim.rs b/crates/smlib/src/trim.rs index 4c9948d967..a528e22324 100644 --- a/crates/smlib/src/trim.rs +++ b/crates/smlib/src/trim.rs @@ -1,7 +1,7 @@ //! Untrim and extend of a sheet's faces. Each returns a new body and leaves its input unchanged; //! faces and holes are chosen by points, as [`crate::ops`] chooses them. -use crate::{Brep, Result, error, ffi, init}; +use crate::{Brep, Result, core, error, ffi, init, route}; /// How [`Brep::extend_sheet`] continues a surface past its side. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -29,12 +29,15 @@ impl Brep { /// all the way round closes on the surface's seam). The face alone, or /// with `in_place` the whole body with it replaced. Tags follow the face and its edges. pub fn untrim_face(&self, point: [f64; 3], tolerance: f64, holes: Holes, outer: bool, in_place: bool) -> Result { - init()?; let holes = match holes { Holes::All => None, Holes::At(at) => Some(at), }; - ffi::trim::untrim_face(&self.0, &point, tolerance, holes, outer, in_place).map(Brep).map_err(|s| match s { + if route::backend() == route::Backend::Solidcore { + return core::trim::untrim_face(self.core()?, point, tolerance, holes, outer, in_place).map(Brep::made_core); + } + init()?; + ffi::trim::untrim_face(self.cpp()?, &point, tolerance, holes, outer, in_place).map(Brep::made).map_err(|s| match s { ffi::ops::ERR_PICK_NONE => crate::Error::NoMatch { point: 0 }, s => error(s), }) @@ -45,13 +48,16 @@ impl Brep { /// `how` says: SMLib's extended surface over the grown box, the face and its sides carrying /// the tags of those they continue. A sheet of another shape is [`crate::Error::Unsupported`]. pub fn extend_sheet(&self, point: [f64; 3], tolerance: f64, distance: f64, how: Continuation) -> Result { + if route::backend() == route::Backend::Solidcore { + return core::trim::extend_sheet(self.core()?, point, tolerance, distance, how).map(Brep::made_core); + } init()?; let mode = match how { Continuation::Natural => ffi::trim::EXTEND_NATURAL, Continuation::Tangent => ffi::trim::EXTEND_TANGENT, Continuation::Curvature => ffi::trim::EXTEND_CURVATURE, }; - ffi::trim::extend_sheet(&self.0, &point, tolerance, distance, mode).map(Brep).map_err(|s| match s { + ffi::trim::extend_sheet(self.cpp()?, &point, tolerance, distance, mode).map(Brep::made).map_err(|s| match s { ffi::ops::ERR_PICK_NONE => crate::Error::NoMatch { point: 0 }, s => error(s), }) @@ -60,8 +66,11 @@ impl Brep { /// A point inside each face, in SMLib's face order: on the face's surface, inside its trims /// (`SmFace::CalculateAnInternalPoint`). pub fn face_points(&self) -> Result> { + if route::backend() == route::Backend::Solidcore { + return core::trim::face_points(self.core()?); + } init()?; let [faces, _, _] = self.counts()?; - ffi::trim::face_points(&self.0, faces).map_err(error) + ffi::trim::face_points(self.cpp()?, faces).map_err(error) } } diff --git a/crates/smlib/tests/ssi_corpus.rs b/crates/smlib/tests/ssi_corpus.rs index d75ce76ae9..c2b33b287a 100644 --- a/crates/smlib/tests/ssi_corpus.rs +++ b/crates/smlib/tests/ssi_corpus.rs @@ -367,7 +367,6 @@ fn parity_with_python_scorer() { /// The cases SMLib's GlobalSurfaceIntersect passes at approximation tolerance 1e-6, as score.py /// grades the corpus runner's output. A fix that makes another case pass adds it here. -#[cfg(feature = "native")] const GSI_1E6_PASS: &[&str] = &[ "c1_cone_plane_ellipse", "c1_cone_plane_hyperbola", @@ -483,10 +482,8 @@ const GSI_1E6_PASS: &[&str] = &[ ]; /// The corpus runner's samples per curve. -#[cfg(feature = "native")] const RUNNER_SAMPLES: usize = 4001; -#[cfg(feature = "native")] fn surface(s: &Value) -> Result { use solidcraft_smlib::ssi::Surface; let f = |k: &str| s.get(k).map(ssi_score::geom::num).ok_or(format!("{k}: missing")); @@ -535,7 +532,6 @@ fn surface(s: &Value) -> Result { /// interval, each UV curve over its own, the surfaces at the UV samples), samples that cannot be /// evaluated dropped, empty UV arrays for a UV curve the method did not return, the runner's type /// names, and no isolated points (the runner only reports those in face mode). -#[cfg(feature = "native")] fn runner_output( r: solidcraft_smlib::Result, a: &solidcraft_smlib::ssi::Surface, @@ -581,25 +577,40 @@ fn runner_output( Outcome::Ran(EngineResult { status: ssi_score::SM_SUCCESS, status_name: None, time_ms: ms, curves, points: Vec::new() }) } -/// Every case through SMLib's GlobalSurfaceIntersect at approximation tolerance 1e-6, scored as -/// score.py scores the corpus runner's `gsi 1e-6` run: exactly [`GSI_1E6_PASS`] pass. -#[cfg(feature = "native")] -#[test] -fn smlib_global_intersect_at_1e_6() { +/// One kernel at a time: the choice of kernel is process-wide. +static KERNEL_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Every case through `backend`'s GlobalSurfaceIntersect at approximation tolerance 1e-6, scored as +/// score.py scores the corpus runner's `gsi 1e-6` run. Prints each case that does not pass. +/// `None` when `SOLIDCRAFT_KERNEL` chose the other kernel: it wins over `set_choice`, and scoring +/// the wrong kernel against this one's list says nothing. +fn global_intersect_scores(backend: solidcraft_smlib::route::Backend) -> Option> { + use solidcraft_smlib::route::{Backend, choice, set_choice}; use solidcraft_smlib::ssi::{Method, intersect}; let t0 = std::time::Instant::now(); let cases = all_cases(); - // SMLib serialises its calls, so the cases run one after another; only the scoring is parallel. - let outcomes: Vec = cases - .iter() - .map(|case| { - let a = surface(&case.surfaces[0]).unwrap_or_else(|e| panic!("{} surface 1: {e}", case.id)); - let b = surface(&case.surfaces[1]).unwrap_or_else(|e| panic!("{} surface 2: {e}", case.id)); - let t = std::time::Instant::now(); - let r = intersect(&a, &b, Method::Global, 1e-6); - runner_output(r, &a, &b, t.elapsed().as_secs_f64() * 1e3) - }) - .collect(); + let outcomes: Vec = { + let _kernel = KERNEL_TESTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + set_choice(backend); + if choice() != backend { + set_choice(Backend::Solidcore); + eprintln!("skipped: SOLIDCRAFT_KERNEL chose the other kernel"); + return None; + } + // The kernels serialise their calls, so the cases run one after another; only the scoring is parallel. + let outcomes = cases + .iter() + .map(|case| { + let a = surface(&case.surfaces[0]).unwrap_or_else(|e| panic!("{} surface 1: {e}", case.id)); + let b = surface(&case.surfaces[1]).unwrap_or_else(|e| panic!("{} surface 2: {e}", case.id)); + let t = std::time::Instant::now(); + let r = intersect(&a, &b, Method::Global, 1e-6); + runner_output(r, &a, &b, t.elapsed().as_secs_f64() * 1e3) + }) + .collect(); + set_choice(Backend::Solidcore); + outcomes + }; let ran = t0.elapsed(); // Status names for the flags, from the SMLib sources the build used, as score.py has them. let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); @@ -626,7 +637,7 @@ fn smlib_global_intersect_at_1e_6() { by_verdict.entry(s.verdict).or_default().push(&s.id); } println!( - "SMLib GlobalSurfaceIntersect, approx tol 1e-6 ({} cases, SMLib {:.1} s, total {:.1} s)", + "{backend:?} GlobalSurfaceIntersect, approx tol 1e-6 ({} cases, kernel {:.1} s, total {:.1} s)", scores.len(), ran.as_secs_f64(), t0.elapsed().as_secs_f64() @@ -637,7 +648,14 @@ fn smlib_global_intersect_at_1e_6() { for s in scores.iter().filter(|s| s.verdict != Verdict::Pass) { println!(" {:<40} {:<15} {}", s.id, s.verdict.as_str(), s.flags.join("; ")); } + Some(scores) +} +/// Exactly [`GSI_1E6_PASS`] pass on the C++ kernel. +#[cfg(feature = "native")] +#[test] +fn smlib_global_intersect_at_1e_6() { + let Some(scores) = global_intersect_scores(solidcraft_smlib::route::Backend::Smlib) else { return }; let passed: std::collections::BTreeSet<&str> = scores.iter().filter(|s| s.verdict == Verdict::Pass).map(|s| s.id.as_str()).collect(); let listed: std::collections::BTreeSet<&str> = GSI_1E6_PASS.iter().copied().collect(); assert_eq!(listed.len(), GSI_1E6_PASS.len(), "GSI_1E6_PASS lists a case twice"); @@ -646,3 +664,16 @@ fn smlib_global_intersect_at_1e_6() { assert!(newly.is_empty() && lost.is_empty(), "now pass (add to GSI_1E6_PASS): {newly:?}; no longer pass: {lost:?}"); assert_eq!(passed.len(), 111); } + +/// solidcore passes every case the C++ kernel passes ([`GSI_1E6_PASS`]). The self-crossing +/// curves it missed (Viviani's figure eight, the crossing loops) are split at their crossings +/// since solidcore's `ssi_split_curves_at_singular_points` (tangency-labs/solidcore#2110). +#[test] +fn solidcore_global_intersect_at_1e_6() { + let Some(scores) = global_intersect_scores(solidcraft_smlib::route::Backend::Solidcore) else { return }; + let passed: std::collections::BTreeSet<&str> = scores.iter().filter(|s| s.verdict == Verdict::Pass).map(|s| s.id.as_str()).collect(); + let listed: std::collections::BTreeSet<&str> = GSI_1E6_PASS.iter().copied().collect(); + let lost: Vec<&str> = listed.difference(&passed).copied().collect(); + let extra: Vec<_> = passed.difference(&listed).collect(); + assert!(lost.is_empty(), "solidcore misses what the C++ kernel passes: {lost:?}; extra passes: {extra:?}"); +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index d8ad6d998d..4d02a68872 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -37,6 +37,8 @@ use crate::ui_kit; use crate::viewport::NavMode; pub const UNITS: &[&str] = &["mm", "cm", "m", "in", "ft"]; +/// The Geometry kernel preference's choices, as `diagnostics.kernel` spells them. +pub const KERNELS: [&str; 2] = ["solidcore", "smlib"]; pub const OPENSCAD_NAV: &str = "openscad"; pub const TOUCHPAD_NAV: &str = "touchpad"; /// A mouse button that can drag the camera. @@ -1159,6 +1161,11 @@ preference_settings! { /// Up/Down and the wheel over a focused value field step its plain number (`value_step`, /// #2320). Off by default: the keys move the text cursor and the wheel scrolls, as before. step_values: bool = false, PAGE_DESIGN => check_id("ui.step_values"); + /// Which geometry kernel answers every operation (one of [`KERNELS`]): `solidcore`, the + /// default, or `smlib`, the C++ kernel. A saved `routed`, the old default, reads as + /// `solidcore` ([`migrate`]). Applied through + /// `diagnostics.kernel`; `SOLIDCRAFT_KERNEL`, when set, wins. + geometry_kernel: String = "solidcore".into(), PAGE_DESIGN; /// When SMLib's full check runs while features are computed (`diagnostics.validation`): /// `each_feature` (the default) checks the bodies each feature keeps once; `each_step`, as /// before, checks after every sweep, boolean and blend; `off` (experimental) never does. @@ -1626,6 +1633,14 @@ pub struct PrefsWindow { /// The Profile operations preference the profiler last followed: applied when it changes, /// so `diagnostics.profile` (an agent) and SOLIDCRAFT_PROFILE can switch it in between. applied_profile: Option, + /// The Geometry kernel preference `diagnostics.kernel` last applied: applied when it changes + /// (and at startup), so an agent's `diagnostics.kernel` in between stands. + applied_kernel: Option, + /// The choice `SOLIDCRAFT_KERNEL` forces when it overrides the preference (the command's + /// `environment` and `choice`). + pub(crate) kernel_environment: Option, + /// The saved kernel is one this build doesn't have (`smlib` without the C++ kernel): the page says so. + pub(crate) kernel_unavailable: bool, /// The appearance last asked of the native window ([`sync_window_theme`]). window_theme: Option, /// The perspective angle the view was last drawn with ([`follow_view_fov`]); 0 before the first. @@ -2298,6 +2313,26 @@ pub fn apply(app: &mut SolveApp, ctx: &egui::Context) { } app.prefs_window.applied_profile = Some(profile); } + // The geometry kernel, through its command (an unknown saved value is the default). `smlib` on + // a build without it is refused: the preference page and the status line say so. + let kernel = KERNELS.iter().copied().find(|k| *k == app.preferences.geometry_kernel).unwrap_or(KERNELS[0]); + if app.prefs_window.applied_kernel.as_deref() != Some(kernel) { + match app.session.execute("diagnostics.kernel", &json!({ "choice": kernel })) { + Ok(v) => { + let environment = v.get("environment").and_then(serde_json::Value::as_bool).unwrap_or(false); + app.prefs_window.kernel_environment = + environment.then(|| v.get("choice").and_then(serde_json::Value::as_str).unwrap_or_default().to_string()); + app.prefs_window.kernel_unavailable = false; + } + Err(e) => { + log::warn!("geometry kernel preference `{kernel}`: {e}"); + app.prefs_window.kernel_unavailable = true; + let message = app.tr("prefs.geometry_kernel_unavailable").to_string(); + app.set_status(message, true); + } + } + app.prefs_window.applied_kernel = Some(kernel.into()); + } apply_file_limits(&app.preferences); // The viewport's display quality (an unknown saved value draws at Normal). solidcraft_engine::doc::display_quality::set_current( @@ -4022,6 +4057,10 @@ pub fn migrate(mut p: Prefs) -> Prefs { p.orbit_around = OrbitAround::Cursor; p.orbit_cursor = false; } + // `routed`, the old default, ran every operation on solidcore. + if p.geometry_kernel == "routed" { + p.geometry_kernel = KERNELS[0].into(); + } if !crate::space_mouse::drives_each_once(p.space_mouse_axis_source) { log::warn!("preferences: the 3D mouse axis assignment {:?} doesn't drive each axis once; using the default", p.space_mouse_axis_source); p.space_mouse_axis_source = crate::space_mouse::SAME_AXES; @@ -4162,6 +4201,10 @@ fn prepare_preferences( { return Err("`validation` must be each_step, each_feature or off".into()); } + // Before `migrate`, which reads the old saved `routed` as the default: asked for now, it is refused. + if p.get("geometry_kernel").is_some_and(|v| !v.as_str().is_some_and(|k| KERNELS.contains(&k))) { + return Err(format!("`geometry_kernel` must be one of {}", KERNELS.join(", "))); + } if let Some(value) = p.get("navigation_bar") { crate::navigation_bar::validate(value)?; } @@ -4185,6 +4228,9 @@ fn prepare_preferences( if !UNITS.contains(&new.default_units.as_str()) { return Err(format!("units must be one of {}", UNITS.join(", "))); } + if !KERNELS.contains(&new.geometry_kernel.as_str()) { + return Err(format!("`geometry_kernel` must be one of {}", KERNELS.join(", "))); + } if !WINDOW_SYSTEMS.contains(&new.window_system.as_str()) { return Err(format!("`window_system` must be one of {}", WINDOW_SYSTEMS.join(", "))); } @@ -4362,6 +4408,61 @@ mod tests { assert!(!solidcraft_engine::profile::enabled()); } + /// The Geometry kernel preference reaches the kernel through diagnostics.kernel, at startup + /// and when it changes, and only a choice the command knows is accepted. `smlib` keeps the C++ + /// kernel reachable, the behaviour from before solidcore was the default. (The kernel choice is + /// process-wide and other tests start harnesses meanwhile, so `smlib` is only set on a build + /// without it, where it is refused and nothing moves.) + #[test] + fn the_geometry_kernel_preference_applies_through_its_command() { + use serde_json::json; + let mut h = crate::scenario::Harness::new(); + h.frames(1); + assert_eq!(h.app.prefs_window.applied_kernel.as_deref(), Some("solidcore"), "applied at startup"); + let r = h.app.session.execute("diagnostics.kernel", &json!({})).unwrap(); + let environment = (r["environment"] == json!(true)).then(|| r["choice"].as_str().unwrap_or_default().to_string()); + assert_eq!(h.app.prefs_window.kernel_environment, environment); + assert!(h.call("ui.prefs", json!({"geometry_kernel": "truck"}))["error"].as_str().is_some_and(|e| e.contains("geometry_kernel"))); + // `routed` is gone: a saved one reads as solidcore (below), asked for now it is refused. + assert!(h.call("ui.prefs", json!({"geometry_kernel": "routed"}))["error"].as_str().is_some_and(|e| e.contains("geometry_kernel"))); + assert_eq!(h.app.preferences.geometry_kernel, "solidcore"); + assert_eq!(super::KERNELS, ["solidcore", "smlib"]); + h.call("ui.prefs", json!({"geometry_kernel": "solidcore"})); + h.frames(2); + assert_eq!(h.app.prefs_window.applied_kernel.as_deref(), Some("solidcore")); + assert!(!h.app.prefs_window.kernel_unavailable); + // Saved with the other preferences. + let saved = serde_json::to_value(&h.app.preferences).unwrap(); + assert_eq!(saved["geometry_kernel"], json!("solidcore")); + // On a build without the C++ kernel, asking for it says so: not a switch to the other one. + if !solidcraft_engine::kernel::smlib_built() { + h.call("ui.prefs", json!({"geometry_kernel": "smlib"})); + h.frames(2); + assert!(h.app.prefs_window.kernel_unavailable); + assert_eq!(h.app.preferences.geometry_kernel, "smlib"); + assert_ne!(h.app.session.execute("diagnostics.kernel", &json!({})).unwrap()["choice"], json!("smlib")); + h.call("ui.prefs", json!({"geometry_kernel": "solidcore"})); + h.frames(2); + assert!(!h.app.prefs_window.kernel_unavailable); + } + } + + /// A saved `routed`, the old default, loads as `solidcore`, without an error; any other value + /// this version doesn't know stays as it was saved and runs on the default, and nothing panics. + #[test] + fn a_saved_routed_kernel_loads_as_solidcore() { + use serde_json::json; + let read = |saved: serde_json::Value| super::migrate(serde_json::from_value::(saved).unwrap()).geometry_kernel; + assert_eq!(read(json!({"geometry_kernel": "routed"})), "solidcore"); + assert_eq!(read(json!({})), "solidcore"); + assert_eq!(read(json!({"geometry_kernel": "smlib"})), "smlib"); + assert_eq!(read(json!({"geometry_kernel": "truck"})), "truck"); + let mut h = crate::scenario::Harness::new(); + h.app.preferences.geometry_kernel = "truck".into(); + h.frames(2); + assert_eq!(h.app.prefs_window.applied_kernel.as_deref(), Some("solidcore")); + } + /// Preferences saved with a setting this version no longer has still load, every other /// setting kept: the removed key is ignored like any unknown one. #[test] diff --git a/crates/ui-egui/src/prefs/design.rs b/crates/ui-egui/src/prefs/design.rs index b2c3424264..8fb59fe936 100644 --- a/crates/ui-egui/src/prefs/design.rs +++ b/crates/ui-egui/src/prefs/design.rs @@ -136,4 +136,28 @@ fn origin_plane_names(app: &mut SolveApp, ui: &mut egui::Ui) { setting::hide_origin(app, ui); ui_kit::text(ui, app.tr("ui.hide_origin_note"), ui_kit::Text::Hint("hide_origin")); }); + // Which geometry kernel answers; `prefs::apply` applies it through diagnostics.kernel. + ui_kit::settings_section(ui, "geometry_kernel", app.tr("prefs.geometry_kernel"), |ui| { + // Under SOLIDCRAFT_KERNEL the control shows the environment's choice, disabled. + let environment = app.prefs_window.kernel_environment.clone(); + ui.add_enabled_ui(environment.is_none(), |ui| { + let options = ["solidcore", "SMLib"]; + let shown = environment.as_deref().unwrap_or(&app.preferences.geometry_kernel); + let mut selected = KERNELS.iter().position(|k| *k == shown).unwrap_or(0); + ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() }); + if let Some(kernel) = KERNELS.get(selected) + && environment.is_none() + && app.preferences.geometry_kernel != *kernel + { + app.preferences.geometry_kernel = (*kernel).into(); + } + }); + ui_kit::text(ui, app.tr("prefs.geometry_kernel_note"), ui_kit::Text::Hint("geometry_kernel")); + if environment.is_some() { + ui_kit::text(ui, app.tr("prefs.geometry_kernel_environment"), ui_kit::Text::Hint("geometry_kernel_environment")); + } + if app.prefs_window.kernel_unavailable { + ui_kit::text(ui, app.tr("prefs.geometry_kernel_unavailable"), ui_kit::Text::Hint("geometry_kernel_unavailable")); + } + }); } diff --git a/deny.toml b/deny.toml index c24dbb4c7f..6769a37209 100644 --- a/deny.toml +++ b/deny.toml @@ -13,3 +13,5 @@ allow-wildcard-paths = true unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] +# solidcore, pinned by rev in Cargo.toml (crates/smlib's second backend). +allow-git = ["https://github.com/tangency-labs/solidcore"] diff --git a/docs/design-container.md b/docs/design-container.md index 36cb8324f1..2621663a90 100644 --- a/docs/design-container.md +++ b/docs/design-container.md @@ -164,12 +164,12 @@ same triangles, colour and paint; the picture shown while opening draws its tria `cache/model.json` records `solidcraft-model-cache/8`, the producer revision `solidcraft-truck-adapter/4`, the application version, the build (source commit, CPU architecture and OS), the evaluation source (below, with the CPU architecture -and OS), the SHA-256 of the exact `document.json` (whose blob references pin every +and OS), the geometry kernel that computed it (`solidcore` or `smlib`), the SHA-256 of the exact `document.json` (whose blob references pin every STEP, mesh and image payload) and of `cache/bodies.bin`, the slices, each kernel body's slice and display share, and the model's records. A cache is read back only -when the format, the producer, the evaluation source, the platform and both +when the format, the producer, the evaluation source, the platform, the kernel and both digests match: the same source compiled for the same platform computes the same -numbers. The application version and the commit are recorded, not compared. +numbers on the same kernel; a design opened on the other kernel is evaluated on it. The application version and the commit are recorded, not compared. The evaluation source (`SOLIDCRAFT_EVALUATION_SOURCE`, `crates/io/build.rs`) is a digest of the committed source a model is computed from: the `doc` crate and every diff --git a/docs/perf.md b/docs/perf.md index 69e32bbfc8..2e7c9daf95 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -9,7 +9,8 @@ What it records, as nested spans on each thread: | Category | Spans | |---|---| -| `smlib` | every SMLib C API call, timed by the C API itself (`steady_clock`) | +| `solidcore` | every call into solidcore, the default kernel | +| `smlib` | every SMLib C API call, timed by the C API itself (`steady_clock`), when the C++ kernel is asked for | | `kernel` | booleans, blends, sweeps, lofts, shell/draft/offset, healing, sewing, conversions, meshing, measuring | | `import` | the stages of a STEP or IGES import: parse, index, each solid, placement, mesh check, convert batches | | `feature` | each feature computed, by its type; `doc evaluate` around them | diff --git a/scripts/ci/test_kernel_rig_plan.py b/scripts/ci/test_kernel_rig_plan.py index 1c3bc1a679..eac3acee2b 100644 --- a/scripts/ci/test_kernel_rig_plan.py +++ b/scripts/ci/test_kernel_rig_plan.py @@ -118,6 +118,7 @@ class Real(unittest.TestCase): for area, globs in {"PATHS-ALL": shared, **areas}.items(): if area != "PATHS-ALL": self.assertTrue(os.path.isfile(os.path.join(ROOT, plan.CASES, area, "KNOWN_FAILING")), area) + self.assertTrue(os.path.isfile(os.path.join(ROOT, plan.CASES, area, "KNOWN_FAILING.solidcore")), area) for g in globs: with self.subTest(area=area, glob=g): rx = plan.glob_regex(g) diff --git a/tests/KNOWN_FAILING_TESTS b/tests/KNOWN_FAILING_TESTS index c12529d133..8eef1d62cb 100644 --- a/tests/KNOWN_FAILING_TESTS +++ b/tests/KNOWN_FAILING_TESTS @@ -1,42 +1,54 @@ -# The cargo tests that fail today, one a line: ` `, with the issue that -# tracks it when there is one. Sorted. `` is nextest's `::`, as the -# junit report names it. CI's test jobs (`cargo xtask known-failing`) fail on a test that fails -# and is not listed here, and on a listed test that passes: a fix removes its line in the same -# pull request. The tests stay un-ignored, so every run shows whether they still fail. -# Refresh (after an SMLib pin bump): download the junit artifacts of develop's latest CI run, one -# directory each (`gh run download -R tangency-labs/solidcraft -n -D /` -# for junit and junit-gpu-1..4), run `cargo xtask known-failing --junit `, then drop each -# FIXED line and add each REGRESSION line with its cause, from the test's failure message. -# Baseline: develop 2bf58241f (CI run 41522001173, x86_64 Linux), after the SMLib a18bbe5 pin (v1.1.0-alpha). -solidcraft-cli::action_matrix::action_matrix_sample 2 baseline actions fail on SMLib: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box, shelled again open at an inner wall) -solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, SMLib thicken status 1001 +# The cargo tests that fail today on the default kernel, solidcore, one a line: ` `, with the issue that tracks it when there is one. Sorted. `` is nextest's +# `::`, as the junit report names it. CI's test jobs (`cargo xtask known-failing`) +# fail on a test that fails and is not listed here, and on a listed test that passes: a fix removes its +# line in the same pull request. The tests stay un-ignored, so every run shows whether they still fail. +# `KNOWN_FAILING_TESTS.smlib` is the same list for the C++ kernel (SOLIDCRAFT_KERNEL=smlib, graded with +# --list). Refresh as that list's header says. +solidcraft-cli::action_matrix::action_matrix_sample 3 baseline actions fail, the same 3 as on C++: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box), 1 fillet (SmSurfOfRevolution Test #7 on oblique_cut_of_cylinder) +solidcraft-cli::model_cache::a_later_round_after_opening_blends_the_saved_rounds_again the later r3 round at the r2 mitre builds solidcore's 10-face corner (7928.743041), the test wants SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310: 15 faces, 7928.142563938 +solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, thicken status 1001, as on C++ solidcraft-doc::eval::chain_tests::a_propagated_chain_that_cannot_build_says_how_to_blend_the_edge_alone the propagated chain now builds a round (9 faces), so there is no refusal to read, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established solidcraft-doc::eval::cost_tests::a_cut_leaves_bodies_it_cannot_reach_as_they_were main's mesh-based version (#10303) not taken; the exact replacement is to come solidcraft-doc::eval::cost_tests::a_tap_marking_searches_only_the_bodies_near_it main's mesh-based version (#10303) not taken; the exact replacement is to come solidcraft-doc::eval::cost_tests::a_tap_marking_still_searches_the_bodies_within_its_reach main's mesh-based version (#10303) not taken; the exact replacement is to come -solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own SMLib meshes a box's planar faces into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12) -solidcraft-engine::cmd::sketch_project::tests::intersection_curve_of_picked_faces_spans_the_whole_surface main's mesh-based version (#9107) not taken; the exact replacement is to come +solidcraft-engine::cmd::edit::tests::inserting_variable_fillet_control_preserves_parameters_at_existing_positions solidcore refuses a variable fillet whose law has a corner (solidcore#430) +solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own the box's planar faces are meshed into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12), as on SMLib +solidcraft-engine::cmd::sketch_project::tests::intersection_curve_of_picked_faces_spans_the_whole_surface its premise fails off truck: the fit-point spline wall's 3 span faces are not one surface patch in the kernel's mesh (surface_patch gives 1 face, the test wants 3), as on SMLib solidcraft-engine::extent_tests::cut_to_the_far_side_of_a_curved_wall main's mesh-based version (#8634) not taken; the exact replacement is to come -solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; SMLib parts the plate there into 2 bodies, the test wants truck's 1 -solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radius SMLib's round at the foot of a boss is not an analytic torus of minor radius 3, so no face of the body is (the test finds none to measure) -solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point SMLib fillet status 1001 on the taper fit (radius 16 closing to 0) -solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once on SMLib a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh evaluation; the surface is the same. Dealt with in solidcore, where the mesher moves (docs/layers.md) +solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; the kernel parts the plate there into 2 bodies, the test wants truck's 1, as on SMLib +solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radius the round at the foot of a boss is not an analytic torus of minor radius 3, so no face of the body is (the test finds none to measure), as on SMLib +solidcraft-engine::tests::a_later_round_at_a_mitred_corner_is_blended_with_the_rounds_there solidcore's corner of three rounds of different radii is 7928.74304145614 with 10 faces; the test wants SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310: 15 faces, 7928.142563938 +solidcraft-engine::tests::chord_and_variable_fillets its 2 → 4 → 2 waist: solidcore refuses a variable fillet whose law has a corner (solidcore#430) +solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point fillet status 1001 on the taper fit (radius 16 closing to 0), as on C++ +solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh mesh of it, where truck's meshes are the same under a rigid move, as on SMLib solidcraft-engine::tests::robustness::fillet_into_an_earlier_round R2 into the earlier round: volume 35923.387 against 35919.524, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established -solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together SMLib fillet status 1001 on the round that ends in the earlier one -solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x a picked edge runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81) +solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together fillet status 1001 on the round that ends in the earlier one, as on C++ +solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x the arc case's rounds remove 72.783 (scaled back) against 72.713 at 1x, as on C++ solidcraft-kernel::blend_corner::tests::a_round_next_to_a_round_of_another_radius the face count is 9, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established -solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds SMLib's check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01 (#2422) +solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds the check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01, as on C++ (#2422) +solidcraft-kernel::blend_corner::tests::three_rounds_at_a_corner the corner's variable-radius transition between rounds of different radii meets its neighbours at 0.144 degrees once its sections are the balls' touch circles (fillet_gn_section_generator, solidcore#2262; 0 with the great circles), as on SMLib d01d7b0; the corner step replaces that transition solidcraft-kernel::blend_smlib::tests::smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why the smooth edge builds (volume 7957.723) where a refusal is expected, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::blend_variable::tests::variable_radius_with_a_corner_through_a_middle_point solidcore refuses a variable fillet whose law has a corner (solidcore#430) solidcraft-kernel::corner_g2::tests::a_concave_corner_takes_the_g2_patch_too the concave G2 corner's curvature jump clear of the corners is 5.5e-2, above the bound, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established -solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges 2.6e-6 to 1.33e-3 past the box (the explicit corner is in progress) -solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner loop's fill misses its neighbours' curvature by 1.6e-3 (tolerance 1e-3), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges past the box, as on C++ +solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner between curvature rounds jumps 2.07 in curvature over 538 stations on solidcore; the test wants it curvature continuous, as SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 builds it solidcraft-kernel::corner_g2::tests::set_back_8_the_g2_corner_lies_between_the_hull_and_the_prisms set back 8: the G2 corner's distance check reads 0.0611, past its bound (volume 47370.642, in range), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established -solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, which SMLib's exact tight box now sees (the explicit G2 corner builds it inside) +solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, as on C++ +solidcraft-kernel::fillet_setback_tests::a_setback_not_past_the_radius_is_refused_naming_the_least solidcore builds setback 5.5 (15 faces) where SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310 refuses it naming the least that builds; solidcraft_smlib::blend_fit names no size on solidcore until then solidcraft-kernel::issue_851_tests::blend_the_arc_top_edge_and_the_top_front_edge removes 59.148 against 60.122, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established solidcraft-kernel::issue_851_tests::fillet_the_arc_top_edge_and_the_front_upright_at_the_arc removes 31.497 against 32.310, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_left_upright removes 42.236 against 43.187, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_upright_at_the_arc removes 41.341 against 42.146, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established solidcraft-kernel::smooth_edges::tests::a_seam_split_in_two_edges_is_drawn_whole the side seam is one edge ([24]), not split in two, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established -solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the partial selection runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81) +solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the R25 rim and a corner arc round together since solidcore#2223, but the two R2 corner arcs picked together fail 1001 in trim_at_creases (cliff_overflow with the crease trims off), though each builds alone (solidcore#2314) +solidcraft-kernel::tests::fillet_three_edges_at_a_corner solidcore builds the corner as an analytic sphere octant (sphere: 1); since 38385ab5d the test wants SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273: one rational patch on the ball's sphere +solidcraft-smlib::core::blend_tests::fillet_all_edges::solidcore_matches_the_cpp_kernel the C++ comparison: solidcore's 8 box corners are analytic spheres, C++ 5e8c9c1's rational splines (SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273) +solidcraft-smlib::core::blend_tests::fillet_variable_linear::solidcore_matches_the_cpp_kernel the C++ comparison: solidcore 5972.141093 against C++ 5e8c9c1's 5972.142573 (SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273) +solidcraft-smlib::core::blend_tests::rounds_on_the_871_part the C++ comparison: solidcore 3874.348250 against C++ 5e8c9c1's 3874.348458 (SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273) +solidcraft-smlib::core::blend_tests::setback_corner::solidcore_matches_the_cpp_kernel the C++ comparison: solidcore 5943.191647 against C++ f916a8d's 5948.231174 (SMLib f916a8d's setback corner (smlib#104), which solidcore ports in solidcore#2310) +solidcraft-smlib::core::convert_tests::cpp_round_trip::a_shelled_rounded_box the C++ round trip: the shelled rounded box C++ 5e8c9c1 builds differs from solidcore's in its arrays (real array 154[0]: -10 against 0), its corners built by SMLib 5e8c9c1's blend (Ball corners as rational patches, the round as its definition, smlib#79), which solidcore ports in solidcore#2273 +solidcraft-smlib::core::offset_tests::mitred_rounds_follow_a_moved_top the C++ comparison: solidcore's offset edges keep their tolerances (offset_edges_keep_source_tolerance, solidcore#2111), C++ at 7ac037e's do not, so the moved tops' volumes are 2e-9 to 2.3e-7 apart; both are 1.2e-8 to 3.1e-7 off the closed form at accuracy 1e-8, solidcore with the fix 2.1e-7 at most solidcraft-smlib::ops::tests::a_setback_corner_left_open_returns_its_hole the open corner returns holes of 9, 27 and 19 edges, not its one hole; SMLib's open_holes counts a seam edge its own face uses twice as free (fixed for v1.1.0-alpha.1) +solidcraft-smlib::ops::tests::a_variable_round_with_a_corner_in_its_law_is_its_balls_union solidcore refuses a variable fillet whose law has a corner (solidcore#430) solidcraft-smlib::ops::tests::fillet_takes_exactly_the_picked_edges one round on the picked edge gives 9 faces, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established diff --git a/tests/KNOWN_FAILING_TESTS.smlib b/tests/KNOWN_FAILING_TESTS.smlib new file mode 100644 index 0000000000..dfbef64d06 --- /dev/null +++ b/tests/KNOWN_FAILING_TESTS.smlib @@ -0,0 +1,43 @@ +# The cargo tests that fail today on the C++ kernel (SOLIDCRAFT_KERNEL=smlib), one a line: ` `, with the issue that +# tracks it when there is one. Sorted. `` is nextest's `::`, as the +# junit report names it. CI's test jobs (`cargo xtask known-failing`) fail on a test that fails +# and is not listed here, and on a listed test that passes: a fix removes its line in the same +# pull request. The tests stay un-ignored, so every run shows whether they still fail. +# Refresh (after an SMLib pin bump): download the junit artifacts of develop's latest CI run, one +# directory each (`gh run download -R tangency-labs/solidcraft -n -D /` +# for junit and junit-gpu-1..4), run `cargo xtask known-failing --junit `, then drop each +# FIXED line and add each REGRESSION line with its cause, from the test's failure message. +# Baseline: develop 38fd4a4f6 (CI run 41157648345, x86_64 Linux), after the SMLib eaa998a pin; SMLib lines as of f916a8d (smlib 5862ee223). +solidcraft-cli::action_matrix::action_matrix_sample 2 baseline actions fail on SMLib: 2 shells (Bad Loop Orient on issue-616-lid-ribs; status 1001 on filleted-shelled-box, shelled again open at an inner wall) +solidcraft-cli::naming_corpus::corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone oracle/83-boundary-loop-thicken does not load: step 8, SMLib thicken status 1001 +solidcraft-doc::eval::chain_tests::a_propagated_chain_that_cannot_build_says_how_to_blend_the_edge_alone the propagated chain now builds a round (9 faces), so there is no refusal to read, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-doc::eval::cost_tests::a_cut_leaves_bodies_it_cannot_reach_as_they_were main's mesh-based version (#10303) not taken; the exact replacement is to come +solidcraft-doc::eval::cost_tests::a_tap_marking_searches_only_the_bodies_near_it main's mesh-based version (#10303) not taken; the exact replacement is to come +solidcraft-doc::eval::cost_tests::a_tap_marking_still_searches_the_bodies_within_its_reach main's mesh-based version (#10303) not taken; the exact replacement is to come +solidcraft-engine::cmd::file::tests::each_format_has_an_export_command_of_its_own SMLib meshes a box's planar faces into 124 triangles, not 12 (the STL is 6284 bytes, the test counts 84 + 50 x 12) +solidcraft-engine::cmd::sketch_project::tests::intersection_curve_of_picked_faces_spans_the_whole_surface main's mesh-based version (#9107) not taken; the exact replacement is to come +solidcraft-engine::extent_tests::cut_to_the_far_side_of_a_curved_wall main's mesh-based version (#8634) not taken; the exact replacement is to come +solidcraft-engine::hole_face_tests::a_hole_wider_than_the_part_says_it_removes_all_of_it a 40 mm hole at (10, 10) touches the plate's edge y = 30 at one point; SMLib parts the plate there into 2 bodies, the test wants truck's 1 +solidcraft-engine::measure_centre_tests::a_fillet_round_a_boss_reports_its_radius SMLib's round at the foot of a boss is not an analytic torus of minor radius 3, so no face of the body is (the test finds none to measure) +solidcraft-engine::tests::fillet_fit_taper_closes_to_a_point SMLib fillet status 1001 on the taper fit (radius 16 closing to 0) +solidcraft-engine::tests::mirror_placed::dragging_a_mirrored_part_meshes_its_copies_once on SMLib a copy of a copy drawn from its first copy's triangles (#10101) is triangulated differently from a fresh evaluation; the surface is the same. Dealt with in solidcore, where the mesher moves (docs/layers.md) +solidcraft-engine::tests::robustness::fillet_into_an_earlier_round R2 into the earlier round: volume 35923.387 against 35919.524, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-engine::tests::shell_tangent_chain_that_opens_every_face_explains_how_to_keep_walls SMLib shell status 1034 on a box with all 12 edges rounded (Ball corners since 5e8c9c1), opened at one face (tangency-labs/smlib#102) +solidcraft-kernel::blend::corner_tests::a_round_ending_in_an_earlier_round_matches_both_together SMLib fillet status 1001 on the round that ends in the earlier one +solidcraft-kernel::blend::corner_tests::corner_blends_hold_at_any_scale at 0.01x a picked edge runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81) +solidcraft-kernel::blend_corner::tests::a_round_next_to_a_round_of_another_radius the face count is 9, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::blend_corner::tests::issue2422_vertical_after_separate_bottom_rounds SMLib's check refuses the fillet: edge and vertex tolerance 0.97 against the body's 0.01 (#2422) +solidcraft-kernel::blend_smlib::tests::smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why the smooth edge builds (volume 7957.723) where a refusal is expected, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::corner_g2::tests::a_concave_corner_takes_the_g2_patch_too the concave G2 corner's curvature jump clear of the corners is 5.5e-2, above the bound, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::corner_g2::tests::a_long_setback_keeps_the_g2_patch_inside_the_box at set backs 11 to 15 the G2 corner patch bulges 2.6e-6 to 1.33e-3 past the box (the explicit corner is in progress) +solidcraft-kernel::corner_g2::tests::curvature_rounds_meet_at_a_g2_corner the corner loop's fill misses its neighbours' curvature by 1.6e-3 (tolerance 1e-3), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::corner_g2::tests::set_back_8_the_g2_corner_lies_between_the_hull_and_the_prisms set back 8: the G2 corner's distance check reads 0.0611, past its bound (volume 47370.642, in range), on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::corner_g2::tests::the_box_corner_g2_patch_is_a_closed_solid_curvature_continuous_to_its_neighbours the fitted G2 corner patch bulges 1.7e-5 past the box, which SMLib's exact tight box now sees (the explicit G2 corner builds it inside) +solidcraft-kernel::issue_851_tests::blend_the_arc_top_edge_and_the_top_front_edge removes 59.148 against 60.122, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::issue_851_tests::fillet_the_arc_top_edge_and_the_front_upright_at_the_arc removes 31.497 against 32.310, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_left_upright removes 42.236 against 43.187, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::issue_851_tests::fillet_the_top_front_edge_and_the_front_upright_at_the_arc removes 41.341 against 42.146, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::smooth_edges::tests::a_seam_split_in_two_edges_is_drawn_whole the side seam is one edge ([24]), not split in two, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established +solidcraft-kernel::tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole the partial selection runs on tangentially into an unblended edge and SMLib's result is not a closed solid (the smooth-vertex End, tangency-labs/smlib#81) +solidcraft-smlib::ops::tests::a_setback_corner_left_open_returns_its_hole the open corner returns holes of 9, 27 and 19 edges, not its one hole; SMLib's open_holes counts a seam edge its own face uses twice as free (fixed for v1.1.0-alpha.1) +solidcraft-smlib::ops::tests::fillet_takes_exactly_the_picked_edges one round on the picked edge gives 9 faces, not the expected count, on SMLib v1.1.0-alpha (a18bbe5), passed on f916a8d; cause being established diff --git a/tests/kernel-cases/README.md b/tests/kernel-cases/README.md index 3bae0e8fb9..517ed227cb 100644 --- a/tests/kernel-cases/README.md +++ b/tests/kernel-cases/README.md @@ -1,17 +1,22 @@ # Kernel regression rig Every geometry case the old kernel (truck) got wrong, recorded in an issue, a pull request or a -crash report, is a use case here with its own right answer. The kernel, SMLib, is held to all of -them. `cargo xtask kernel-cases` runs them and writes [`docs/kernel-cases.md`](../../docs/kernel-cases.md). +crash report, is a use case here with its own right answer. The kernel, solidcore, is held to all +of them, as is the C++ SMLib kernel it was ported from when asked for. `cargo xtask kernel-cases` +runs them on solidcore; `--kernel smlib` runs them on C++ and writes +[`docs/kernel-cases.md`](../../docs/kernel-cases.md). ```sh -cargo xtask kernel-cases # every case +cargo xtask kernel-cases # every case, on solidcore cargo xtask kernel-cases --cli target/release/solidcraft-cli --out /tmp/run # with a CLI already built -cargo run -p solidcraft-cli --features smlib -- kernel-cases tests/kernel-cases/booleans/ # one case +cargo xtask kernel-cases --kernel both --cli target/release/solidcraft-cli # SMLib and solidcore side by side +cargo run -p solidcraft-cli -- kernel-cases tests/kernel-cases/booleans/ # one case ``` -The CLI links SMLib by default (`scripts/fetch-smlib.sh` fetches the prebuilt library first); -built with `--no-default-features`, every operation answers that SMLib is not built. The run's output folder (`--out`, default `target/kernel-cases`) +The CLI builds with solidcore alone by default. The C++ SMLib kernel is opt-in at build time: +`--features smlib` links it (`scripts/fetch-smlib.sh` fetches the prebuilt library first), and +`--kernel smlib` or `both` without `--cli` builds the CLI that way. On a build without it, asking +for SMLib answers that this build doesn't include it. The run's output folder (`--out`, default `target/kernel-cases`) gets `results.json`, every case's full result, and `dumps/-/`: the inputs of each SMLib call a case failed in, for replay (the CLI's `--dumps=DIR`, which the rig always passes). `--validation MODE` runs the cases' features under that mode of SMLib's check @@ -19,6 +24,13 @@ SMLib call a case failed in, for replay (the CLI's `--dumps=DIR`, which the rig once), `each_step` (after every sweep, boolean and blend, as before) or `off`; the `KNOWN_FAILING` lists are each_feature's. +`--kernel solidcore|smlib|both` (default `solidcore`) picks the backend each case's process runs on, +through `SOLIDCRAFT_KERNEL`. A `solidcore` run keeps its `results.json`, `dumps/` and `report.md` +in `/solidcore/` and leaves `docs/kernel-cases.md`, which is the `smlib` run's, alone. `both` runs every case on each +backend, keeps `docs/kernel-cases.md` as the SMLib report and writes the two side by side to +[`docs/kernel-cases-backends.md`](../../docs/kernel-cases-backends.md) (cases that differ first) +and `/backends.json`. + ## Rules - **The answer never comes from a kernel.** Not from truck's output, nor any commercial @@ -126,17 +138,19 @@ the frozen truck-geometry PRs, a `kernel-paused` PR), for the report's coverage Every area is a regression suite: each case is one small case of one issue, with its exact answer. `.github/workflows/kernel-rig.yml` runs them, and the ladder, on pull requests (not drafts). A plan job (`scripts/ci/kernel_rig_plan.py`) picks the areas from the changed files, one -job builds `solidcraft-cli` (release, SMLib) and the rig's runner, and a matrix of shards, one per +job builds `solidcraft-cli` (release, `--features smlib`, for the ladder's `--kernel smlib`) and the rig's runner, and a matrix of shards, one per selected area plus the ladder, runs them in parallel; the `kernel-rig` check passes when every selected shard does (and when none was selected). Pushes to main and develop, merge groups, the release gate (`release-gate.yml`) and a run by hand run every area. Each area's shard runs: ```sh cargo xtask kernel-cases --area --cli target/release/solidcraft-cli \ - --known-failing tests/kernel-cases//KNOWN_FAILING + --known-failing tests/kernel-cases//KNOWN_FAILING.solidcore ``` -`/KNOWN_FAILING` lists the cases of that area that fail today, sorted, one a line with its +The gate grades the kernel the run is on, so a list is one kernel's: `/KNOWN_FAILING.solidcore` +for the default, `/KNOWN_FAILING` with `--kernel smlib` (`--kernel both` is refused). Each lists +the cases of that area that fail today on it, sorted, one a line with its cause (` `; `#` starts a comment; the header names the baseline: the develop commit and SMLib revision it was taken from). The gate fails on a **regression**, a case that fails (or crashes, hangs, or is not supported yet) and is not listed, and on a @@ -208,11 +222,15 @@ running part grows one tier at a time through `base`), so the last case carries `method` gives the derivation). ```sh -cargo xtask kernel-cases --ladder --cli target/release/solidcraft-cli -cargo xtask kernel-cases --ladder --cli target/release/solidcraft-cli --gate -cargo xtask kernel-cases --ladder --cli target/release/solidcraft-cli --record "why" +cargo xtask kernel-cases --ladder --kernel smlib --cli target/release/solidcraft-cli +cargo xtask kernel-cases --ladder --kernel smlib --cli target/release/solidcraft-cli --gate +cargo xtask kernel-cases --ladder --kernel smlib --cli target/release/solidcraft-cli --record "why" ``` +The golden is the C++ kernel's, so these say `--kernel smlib`. Without it a run is on solidcore and is +graded against the same golden; today that differs on one case, +`ladder/09-shells-and-offsets/010-shell-under-rounded-top`. + The ladder needs no known-failing list: the golden is the reference. Its shard in the kernel rig gate runs with `--gate`, which fails on any case whose status or counts differ from the golden, better or worse, and on a case with no golden; times are not compared there (they are the diff --git a/tests/kernel-cases/assemblies/KNOWN_FAILING.solidcore b/tests/kernel-cases/assemblies/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..088a09b0b6 --- /dev/null +++ b/tests/kernel-cases/assemblies/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The assemblies cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/booleans/KNOWN_FAILING.solidcore b/tests/kernel-cases/booleans/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..862819c473 --- /dev/null +++ b/tests/kernel-cases/booleans/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The booleans cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/construction/KNOWN_FAILING.solidcore b/tests/kernel-cases/construction/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..a84638c497 --- /dev/null +++ b/tests/kernel-cases/construction/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The construction cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/continuity/KNOWN_FAILING.solidcore b/tests/kernel-cases/continuity/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..61ec46e21e --- /dev/null +++ b/tests/kernel-cases/continuity/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The continuity cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/crashes/KNOWN_FAILING.solidcore b/tests/kernel-cases/crashes/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..065f5bb4ef --- /dev/null +++ b/tests/kernel-cases/crashes/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The crashes cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/fillets/KNOWN_FAILING.solidcore b/tests/kernel-cases/fillets/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..9583fe6efe --- /dev/null +++ b/tests/kernel-cases/fillets/KNOWN_FAILING.solidcore @@ -0,0 +1,37 @@ +# The fillets cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. +1080-fillet-equal-slope-basin-1-mm-rim-loop-4 [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam +1080-fillet-sloped-basin-1-mm-rim-r4-2-edges [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam +1080-fillet-sloped-basin-1-mm-rim-r4-4-edges [fail] continuity_order: got {'body': 'Slab', 'edges': 'blend', 'order': 'g0', want >= g1 at every blend seam +1207-fillet-bar-top-edge-after-boss-arc-round-r0-5 [fail] step 4 (solid.fillet): body `Arm`: the operation failed: SMLib fillet: SMLib's check refuses the result: 5 failed tests: SmBSplineCurve::Test #15 Bad Repeated CPts: BSplineCurve has Internal Coincident ControlPoints (causes +2893-chamfer-distance-and-angle-past-the-wall-refused [fail] refused, but not because "angle": step 2 (solid.chamfer): SMLib chamfer: operation: SMLib rejected the input (SM_ERR_INVALID_INPUT) +2893-chamfer-two-distances-elliptical-rim [fail] refused, but not because "not supported yet": step 3 (solid.chamfer): SMLib chamfer: operation: SMLib failed (status 1001) (tangent propagation blended the picked edges' tangent chain with them; with it off, tangent_chain: +4782-fillet-bar-edge-wider-than-both-faces [fail] continuity_order: got Box: no null seams want >= g1 at every blend seam +5158-fillet-round-just-past-a-10mm-face [fail] step 2 (solid.fillet): SMLib fillet: operation: SMLib failed (status 1001) +851-fillet-arc-then-top-front [fail] fillet status 1001, as on SMLib 5e8c9c1 (see KNOWN_FAILING): with tangent_chain true since 21f00a917 the top front edge's chain takes the arc round's end curve, where the R2 ball sits on that round's centre circle (a torus of tube-centre radius 0, the three-edge rolling-ball corner) +851-fillet-top-front-then-upright [fail] fillet status 1001, as on SMLib 5e8c9c1 (see KNOWN_FAILING): with tangent_chain true since 21f00a917 the upright's chain takes the first round's end curve, where the R2 ball sits on that round's axis (a torus of tube-centre radius 0, the three-edge rolling-ball corner) +9-fillet-corner-curvature-with-reduce-fit [unsupported] solid.fillet builds `corner: g2` with `fit: exact` only +9-fillet-corner-curvature-with-taper-fit [unsupported] solid.fillet builds `corner: g2` with `fit: exact` only +cov-fillet-g2-corner-beside-a-cylinder [fail] step 6 (solid.fillet): the open setback corner is not a closed solid, as on SMLib d01d7b0 +cov-fillet-g2-five-edge-apex-corner [fail] step 12 (solid.fillet): invalid input: the setback must be more than 3.5056 mm at the corner (-0.000, 0.000, 30.000): the rounds' contact lines meet 3 +cov-fillet-g2-four-edge-apex-corner [fail] step 11 (solid.fillet): invalid input: the setback must be more than 3.9027 mm at the corner (0.000, 0.000, 30.000): the rounds' contact lines meet 3. +cov-fillet-g2-mixed-corner [unsupported] refused as on SMLib d01d7b0: a G2 corner is set back only where every edge meeting it is rounded and all are convex or all concave +cov-fillet-g2-oblique-three-edge-corner [unsupported] refused on solidcore: its three edges meet at different wedge angles, and one curvature fillet takes the first edge's equal-area thumbweight for every edge (solidcore#2250, issuecomment-6563419897); SMLib d01d7b0 fails it too (see KNOWN_FAILING) +cov-fillet-g2-pocket-corner [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING) +cov-fillet-section-curvature-pipe-on-plate-r2 [fail] solidcore builds the order-2 section as SMLib d01d7b0 did (40302.5908, G2 on both seams); the case now grades SMLib 5e8c9c1's section, the round as its definition, G3 on the pipe and 40307.8856 (fork 4a10ed8 and 79db628, to be ported in solidcore#2273) +cov-fillet-turn-swept-boss-base-r1 [fail] as on SMLib: solid.fillet has no `turn`; the corners are mitred (12508.9675, the Mitre case's volume) +cov-fillet-turn-swept-pocket-sharp-corner-refused-r1 [fail] as on SMLib: solid.fillet has no `turn`, so the sharp concave corners are mitred, not refused +cov-junction-boss-tilt-20-root-round [fail] fillet status 1001 on a cylinder tilted 20 degrees into the plate, as on SMLib +cov-junction-boss-tilt-40-root-round [fail] fillet status 1001 on a cylinder tilted 40 degrees into the plate, as on SMLib +cov-junction-tee-equal-round [fail] the result is not a closed solid, as on SMLib: equal pipes' intersection is two plane ellipses crossing, a corner the old corner code cannot join (the corner step) +cov-junction-y-junction-120-round [unsupported] refused as on SMLib: the faces between the three intersection curves narrow to nothing at the two triple points, so the room check trips (the corner step) +report-drafted-peg-front-and-concave-rounds-together-r2 [unsupported] step 13 (solid.fillet): invalid input: not supported yet: the fillet meets a corner of this kind, which SMLib's blends do not handle yet (where its ed +report-fillet-concave-arc-and-front-top-edge-together-r5-solver-residues [fail] bbox.min: got [0.4892940775280783, 0.05636444090890084, 0.0] want [0.4892940775280783, 0.05792545052986289, 0.0] +report-fillet-concave-arc-and-front-top-edge-together-r7-solver-residues [fail] bbox.min: got [0.4892940775169876, 0.02441657049978923, 0.0] want [0.4892940775280783, 0.05792545052986289, 0.0] +report-fillet-curvature-three-edges-at-bar-corner [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING) +report-fillet-curvature-three-edges-at-bar-corner-solver-residues [fail] continuity_order: G1 at one seam where the corner patch meets a contact face (g2 wanted), as on SMLib d01d7b0 (see KNOWN_FAILING) +report-peg-front-and-concave-chamfers-together-d2 [fail] step 13 (solid.chamfer): SMLib chamfer: operation: SMLib failed (status 1001) +report-peg-front-and-concave-rounds-together-r2 [fail] continuity_order: got {'body': 'Peg', 'edges': 'blend', 'order': 'g0', ' want >= g1 at every blend seam +report-peg-front-and-concave-rounds-together-r5 [fail] continuity_order: got {'body': 'Peg', 'edges': 'blend', 'order': 'g0', ' want >= g1 at every blend seam +report-peg-step-front-and-concave-rounds-together-r2 [fail] continuity_order: got {'body': 'Peg', 'edges': 'blend', 'order': 'g0', ' want >= g1 at every blend seam diff --git a/tests/kernel-cases/frames/KNOWN_FAILING.solidcore b/tests/kernel-cases/frames/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..ec1fb5489f --- /dev/null +++ b/tests/kernel-cases/frames/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The frames cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/history/KNOWN_FAILING.solidcore b/tests/kernel-cases/history/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..6fe1d5cf9c --- /dev/null +++ b/tests/kernel-cases/history/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The history cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/import/KNOWN_FAILING.solidcore b/tests/kernel-cases/import/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..1a5d014b90 --- /dev/null +++ b/tests/kernel-cases/import/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The import cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/inspect/KNOWN_FAILING.solidcore b/tests/kernel-cases/inspect/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..54048005d1 --- /dev/null +++ b/tests/kernel-cases/inspect/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The inspect cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/plastic/KNOWN_FAILING.solidcore b/tests/kernel-cases/plastic/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..5c65589d4c --- /dev/null +++ b/tests/kernel-cases/plastic/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The plastic cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/sheet-metal/KNOWN_FAILING.solidcore b/tests/kernel-cases/sheet-metal/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..ef474ce810 --- /dev/null +++ b/tests/kernel-cases/sheet-metal/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The sheet-metal cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/shell/KNOWN_FAILING.solidcore b/tests/kernel-cases/shell/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..e768469d0c --- /dev/null +++ b/tests/kernel-cases/shell/KNOWN_FAILING.solidcore @@ -0,0 +1,4 @@ +# The shell cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. +cov-shell-again-open-at-an-inner-wall-of-a-shelled-box [fail] step 9 (solid.shell): SMLib shell: operation: SMLib failed (status 1001) diff --git a/tests/kernel-cases/solids/KNOWN_FAILING.solidcore b/tests/kernel-cases/solids/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..3f60017a61 --- /dev/null +++ b/tests/kernel-cases/solids/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The solids cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/surfaces/KNOWN_FAILING.solidcore b/tests/kernel-cases/surfaces/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..f06f0629c3 --- /dev/null +++ b/tests/kernel-cases/surfaces/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The surfaces cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/sweeps/KNOWN_FAILING.solidcore b/tests/kernel-cases/sweeps/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..123b953f0f --- /dev/null +++ b/tests/kernel-cases/sweeps/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The sweeps cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/tessellation/KNOWN_FAILING.solidcore b/tests/kernel-cases/tessellation/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..b6d4bf06ab --- /dev/null +++ b/tests/kernel-cases/tessellation/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The tessellation cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/tests/kernel-cases/threads/KNOWN_FAILING.solidcore b/tests/kernel-cases/threads/KNOWN_FAILING.solidcore new file mode 100644 index 0000000000..6c945ac4b4 --- /dev/null +++ b/tests/kernel-cases/threads/KNOWN_FAILING.solidcore @@ -0,0 +1,3 @@ +# The threads cases that fail today on solidcore, one a line: ` `. Sorted. +# The kernel rig gate fails on a case that fails and is not listed, and on a listed case that passes. +# Baseline: solidcore pin de85a3e00, tests/kernel-cases run by `cargo xtask kernel-cases`, x86_64 Linux. diff --git a/xtask/src/kernel_cases.rs b/xtask/src/kernel_cases.rs index e931beb31e..35f3d66e06 100644 --- a/xtask/src/kernel_cases.rs +++ b/xtask/src/kernel_cases.rs @@ -1,12 +1,18 @@ //! `cargo xtask kernel-cases`: the kernel regression rig. Runs every case in -//! `tests/kernel-cases///` through `solidcraft-cli kernel-cases` on SMLib, one process -//! per case (so a crash or a hang is that case's result, not the run's), and writes -//! `docs/kernel-cases.md`: results per case, coverage per area and per source, and the issues and -//! PRs looked at and skipped. Each case is graded against its own answer. +//! `tests/kernel-cases///` through `solidcraft-cli kernel-cases` on solidcore, or the +//! C++ SMLib kernel when asked for, one process per case (so a crash or a hang is that case's result, not the run's), and writes +//! its report: results per case, coverage per area and per source, and the issues and PRs looked +//! at and skipped. Each case is graded against its own answer. //! //! The run's output folder (`--out`, default `target/kernel-cases`) holds `results.json`, every //! case's full result, and `dumps/-/`, the inputs of each SMLib call a case's run //! failed in (`solidcraft-cli kernel-cases --dumps`), for replay. +//! +//! `--kernel solidcore|smlib|both` (default solidcore) picks the backend each case's process runs +//! on (`SOLIDCRAFT_KERNEL`). A solidcore run keeps its results, dumps and report in +//! `/solidcore/`; an smlib run writes `docs/kernel-cases.md`; `both` runs every case on each, +//! keeps `docs/kernel-cases.md` as the SMLib report and writes the side by side to +//! `docs/kernel-cases-backends.md` and `/backends.json`. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -20,6 +26,9 @@ use crate::{cargo, root}; const DEFAULT_TIMEOUT_S: u64 = 180; +/// The backends `--kernel` may name, as `SOLIDCRAFT_KERNEL` takes them. +const BACKENDS: [&str; 2] = ["smlib", "solidcore"]; + /// How many cases ended in each status. type StatusCounts = BTreeMap; @@ -30,6 +39,8 @@ pub(crate) struct Options { pub(crate) jobs: usize, /// The run's output folder: `results.json` and the SMLib dumps (`dumps/`). out: PathBuf, + /// The backends each case runs on (`--kernel`, default solidcore alone). + pub(crate) kernels: Vec<&'static str>, /// `--known-failing FILE`: run as a regression gate against that baseline (see [`gate`]). known_failing: Option, /// `--validation MODE`: when SMLib's full check runs on the cases' features (each_step, @@ -37,28 +48,56 @@ pub(crate) struct Options { validation: Option, } +impl Options { + /// A backend's output folder: the run's own for SMLib, `//` for any other. + fn out_of(&self, kernel: &str) -> PathBuf { + match kernel { + "smlib" => self.out.clone(), + other => self.out.join(other), + } + } +} + /// The run's options; `out` is its output folder unless `--out` gives one. pub(crate) fn options(rest: &[&str], out: &str) -> Result { let arg = |name: &str| rest.iter().position(|a| *a == name).and_then(|i| rest.get(i + 1)).map(|s| s.to_string()); let secs = arg("--timeout").map(|t| t.parse::().map_err(|e| format!("--timeout: {e}"))).transpose()?.unwrap_or(DEFAULT_TIMEOUT_S); let jobs = arg("--jobs").map(|j| j.parse::().map_err(|e| format!("--jobs: {e}"))).transpose()?.unwrap_or(4).clamp(1, 32); let out = arg("--out").map_or_else(|| root().join(out), PathBuf::from); + let kernels = match arg("--kernel").as_deref() { + Some("smlib") => vec![BACKENDS[0]], + None | Some("solidcore") => vec![BACKENDS[1]], + Some("both") => BACKENDS.to_vec(), + Some(k) => return Err(format!("--kernel: unknown backend '{k}' (smlib, solidcore or both)")), + }; + if rest.contains(&"--route") { + return Err("--route is gone: a run has one kernel, --kernel solidcore, smlib or both".into()); + } let known_failing = arg("--known-failing").map(|p| { let p = PathBuf::from(p); if p.is_absolute() { p } else { root().join(p) } }); + // A list is one kernel's: the gate grades the run's one kernel against it. + if known_failing.is_some() && kernels.len() != 1 { + return Err("--known-failing: a list is one kernel's, so not with --kernel both".into()); + } let validation = arg("--validation"); if let Some(v) = validation.as_deref().filter(|v| !["each_step", "each_feature", "off"].contains(v)) { return Err(format!("--validation must be each_step, each_feature or off, not `{v}`")); } - Ok(Options { cli: arg("--cli"), area: arg("--area"), timeout: Duration::from_secs(secs), jobs, out, known_failing, validation }) + Ok(Options { cli: arg("--cli"), area: arg("--area"), timeout: Duration::from_secs(secs), jobs, out, kernels, known_failing, validation }) } /// Every case's full result as JSON, into the run's output folder. pub(crate) fn save_results(o: &Options, results: &[Value]) -> Result<(), String> { - std::fs::create_dir_all(&o.out).map_err(|e| format!("{}: {e}", o.out.display()))?; - let path = o.out.join("results.json"); - let text = serde_json::to_string_pretty(results).map_err(|e| e.to_string())?; + save_json(&o.out, "results.json", results) +} + +/// `value` as pretty JSON into `dir/name`. +fn save_json(dir: &Path, name: &str, value: &T) -> Result<(), String> { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let path = dir.join(name); + let text = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?; std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display())) } @@ -87,26 +126,66 @@ fn cases(dir: &Path, only: Option<&str>) -> Result, String> { /// One case, in its own process, killed after the timeout; `path` is the case's directory under /// `tests/kernel-cases/`. -fn run_one(o: &Options, case: &Path) -> Value { - let mut r = run_case(o, case); +fn run_one(o: &Options, case: &Path, kernel: &str) -> Value { + let mut r = run_case(o, case, kernel); let base = root().join("tests/kernel-cases"); r["path"] = json!(case.strip_prefix(&base).unwrap_or(case).to_string_lossy().replace('\\', "/")); r } -fn run_case(o: &Options, case: &Path) -> Value { +/// Waits for `child` for at most `timeout` while reading its stdout and stderr, so a child that +/// writes more than the pipe buffer (64 KiB on Linux) is not blocked in `write` and taken for a +/// hang. `None`: the child was still running at the timeout and has been killed. +fn wait_draining(mut child: std::process::Child, timeout: Duration) -> Result, String> { + fn drain(pipe: Option) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut buf = Vec::new(); + if let Some(mut p) = pipe { + let _ = p.read_to_end(&mut buf); + } + buf + }) + } + let (stdout, stderr) = (drain(child.stdout.take()), drain(child.stderr.take())); + let start = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if start.elapsed() > timeout => { + let _ = child.kill(); + let _ = child.wait(); + // The readers are left to finish on their own: a grandchild (`cargo run`'s CLI) + // may still hold the pipes open. + return Ok(None); + } + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(e) => return Err(format!("wait: {e}")), + } + }; + let join = |h: std::thread::JoinHandle>| h.join().map_err(|_| "output: a pipe reader stopped".to_string()); + Ok(Some(std::process::Output { status, stdout: join(stdout)?, stderr: join(stderr)? })) +} + +/// The features `cargo run` builds the CLI with for a run on `kernels`: the C++ kernel is opt-in at +/// build time (`--features smlib`), so only a run that asks for it builds it. One build for the +/// whole run (`both` included), so the cases don't rebuild the CLI between kernels. +fn cli_features(kernels: &[&str]) -> &'static [&'static str] { + if kernels.contains(&"smlib") { &["--features", "smlib"] } else { &[] } +} + +fn run_case(o: &Options, case: &Path, kernel: &str) -> Value { let rel = case.strip_prefix(root()).unwrap_or(case).to_path_buf(); let mut cmd = match &o.cli { Some(bin) => Command::new(bin), None => { let mut c = cargo(); - c.args(["run", "-q", "--release", "-p", "solidcraft-cli", "--features", "smlib", "--"]); + c.args(["run", "-q", "--release", "-p", "solidcraft-cli"]).args(cli_features(&o.kernels)).arg("--"); c } }; let mut dumps = std::ffi::OsString::from("--dumps="); - dumps.push(o.out.join("dumps")); - cmd.current_dir(root()).args(["kernel-cases", "--json"]).arg(dumps); + dumps.push(o.out_of(kernel).join("dumps")); + cmd.env("SOLIDCRAFT_KERNEL", kernel).current_dir(root()).args(["kernel-cases", "--json"]).arg(dumps); if let Some(v) = &o.validation { cmd.arg(format!("--validation={v}")); } @@ -116,28 +195,17 @@ fn run_case(o: &Options, case: &Path) -> Value { let id = case.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); json!({"case": id, "area": area, "status": status, "error": error}) }; - let mut child = match cmd.spawn() { + let child = match cmd.spawn() { Ok(c) => c, Err(e) => return base("fail", format!("solidcraft-cli: {e}")), }; // A case may need longer than the run's timeout (`timeout_s`, at most 30 min). let timeout = read(&case.join("expected.json"))["timeout_s"].as_u64().map_or(o.timeout, |s| Duration::from_secs(s.min(1800)).max(o.timeout)); let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => break, - Ok(None) if start.elapsed() > timeout => { - let _ = child.kill(); - let _ = child.wait(); - return base("hang", format!("no result after {} s", timeout.as_secs())); - } - Ok(None) => std::thread::sleep(Duration::from_millis(50)), - Err(e) => return base("fail", format!("wait: {e}")), - } - } - let out = match child.wait_with_output() { - Ok(o) => o, - Err(e) => return base("fail", format!("output: {e}")), + let out = match wait_draining(child, timeout) { + Ok(Some(out)) => out, + Ok(None) => return base("hang", format!("no result after {} s", timeout.as_secs())), + Err(e) => return base("fail", e), }; match serde_json::from_slice::(&out.stdout).ok().and_then(|v| v.as_array().and_then(|a| a.first().cloned())) { Some(mut r) => { @@ -154,11 +222,27 @@ fn run_case(o: &Options, case: &Path) -> Value { } } +/// Every case on the run's one backend (the ladder's: it refuses `--kernel both`). pub(crate) fn run_all(o: &Options, list: &[PathBuf]) -> Vec { + run_on(o, list, &o.kernels).into_iter().next().unwrap_or_default() +} + +/// Every case on each backend, one job pool for all the runs; the results per backend, in order. +fn run_on(o: &Options, list: &[PathBuf], kernels: &[&str]) -> Vec> { + // Without --cli each case is `cargo run`: build the CLI once first, so the first cases don't + // spend their timeout compiling it (the C++ kernel's build most of all). + if o.cli.is_none() { + let mut c = cargo(); + c.args(["build", "-q", "--release", "-p", "solidcraft-cli"]).args(cli_features(kernels)); + if let Err(e) = c.status().map_err(|e| e.to_string()).and_then(|s| if s.success() { Ok(()) } else { Err(s.to_string()) }) { + eprintln!("kernel-cases: building solidcraft-cli failed ({e}); each case will say why"); + } + } + let jobs = list.len() * kernels.len(); let next = Arc::new(Mutex::new(0usize)); - let results = Arc::new(Mutex::new(vec![Value::Null; list.len()])); + let results = Arc::new(Mutex::new(vec![Value::Null; jobs])); std::thread::scope(|s| { - for _ in 0..o.jobs.min(list.len().max(1)) { + for _ in 0..o.jobs.min(jobs.max(1)) { let (next, results) = (Arc::clone(&next), Arc::clone(&results)); s.spawn(move || { loop { @@ -168,9 +252,15 @@ pub(crate) fn run_all(o: &Options, list: &[PathBuf]) -> Vec { *n += 1; i }; - let Some(case) = list.get(i) else { return }; - let r = run_one(o, case); - println!("{:<11} {}", r["status"].as_str().unwrap_or("?").to_uppercase(), case.strip_prefix(root()).unwrap_or(case).display()); + // Case by case, each on every backend in turn. + let (Some(case), Some(kernel)) = (list.get(i / kernels.len().max(1)), kernels.get(i % kernels.len().max(1))) else { return }; + let r = run_one(o, case, kernel); + let tag = if kernels.len() > 1 { format!("{kernel:<10} ") } else { String::new() }; + println!( + "{:<11} {tag}{}", + r["status"].as_str().unwrap_or("?").to_uppercase(), + case.strip_prefix(root()).unwrap_or(case).display() + ); if let Ok(mut all) = results.lock() && let Some(slot) = all.get_mut(i) { @@ -180,12 +270,12 @@ pub(crate) fn run_all(o: &Options, list: &[PathBuf]) -> Vec { }); } }); - Arc::try_unwrap(results).ok().and_then(|m| m.into_inner().ok()).unwrap_or_default() + let all = Arc::try_unwrap(results).ok().and_then(|m| m.into_inner().ok()).unwrap_or_default(); + (0..kernels.len()).map(|k| all.iter().skip(k).step_by(kernels.len().max(1)).cloned().collect()).collect() } -pub(crate) fn cell(r: Option<&Value>) -> String { - let Some(r) = r else { return String::new() }; - let status = r["status"].as_str().unwrap_or("?"); +/// What went wrong in a result: its failed checks, then its error. +fn notes(r: &Value) -> Vec { let mut notes: Vec = r["checks"] .as_array() .into_iter() @@ -196,6 +286,13 @@ pub(crate) fn cell(r: Option<&Value>) -> String { if let Some(e) = r["error"].as_str() { notes.push(e.chars().take(160).collect()); } + notes +} + +pub(crate) fn cell(r: Option<&Value>) -> String { + let Some(r) = r else { return String::new() }; + let status = r["status"].as_str().unwrap_or("?"); + let notes = notes(r); let s = match status { "pass" => "pass".to_string(), "unsupported" => "not supported yet".to_string(), @@ -256,7 +353,9 @@ fn source_kinds(dir: &Path, cited: &BTreeMap>) -> Result Result { +/// The report of one backend's run into `to` (relative to the repository root, or absolute); +/// `kernel` is the backend's label, `env` what its processes were given. +fn write_report(dir: &Path, list: &[PathBuf], results: &[Value], kernel: &str, env: &str, to: &Path) -> Result { let key = |r: &Value| (r["area"].as_str().unwrap_or("").to_string(), r["case"].as_str().unwrap_or("").to_string()); let by_case: BTreeMap<(String, String), &Value> = results.iter().map(|r| (key(r), r)).collect(); // Coverage: per area, cases and their statuses; per source, its cases. @@ -289,6 +388,9 @@ fn write_report(dir: &Path, list: &[PathBuf], results: &[Value]) -> Result-/`). See [`tests/kernel-cases/README.md`](../tests/kernel-cases/README.md) for the format. Every area is also a regression gate: CI's Kernel rig workflow runs them on every pull request that touches the kernel and fails on a case that fails and its area's `KNOWN_FAILING` (such as [`fillets/KNOWN_FAILING`](../tests/kernel-cases/fillets/KNOWN_FAILING)) does not list, or a listed case that passes.\n\n", ); + if kernel != "smlib" { + md += &format!("**This run: `{env}`.**\n\n"); + } md += "## Coverage\n\n| Area | Cases | Results |\n|---|---|---|\n"; for (area, (n, statuses)) in &areas { md += &format!("| {area} | {n} | {} |\n", statuses.iter().map(|(s, c)| format!("{c} {s}")).collect::>().join(", ")); @@ -335,13 +437,96 @@ fn write_report(dir: &Path, list: &[PathBuf], results: &[Value]) -> Result &'static str { + match status { + "pass" => "pass", + "fail" => "fail", + _ => "other", + } +} + +/// A result's commands' time (or, without one, its process's). +fn seconds(r: &Value) -> Option { + r["seconds"].as_f64().or_else(|| r["process_seconds"].as_f64()) +} + +/// The two backends side by side: per case (area, id) each one's status, time and what went wrong, +/// as JSON, and the Markdown report: a summary, then per area the cases that differ first. +fn side_by_side(smlib: &[Value], solidcore: &[Value]) -> (Value, String) { + let key = |r: &Value| (r["area"].as_str().unwrap_or("").to_string(), r["case"].as_str().unwrap_or("").to_string()); + let mut pairs: BTreeMap<(String, String), [Option<&Value>; 2]> = BTreeMap::new(); + for (k, rs) in [smlib, solidcore].iter().enumerate() { + for r in rs.iter().filter(|r| !r.is_null()) { + if let Some(slot) = pairs.entry(key(r)).or_default().get_mut(k) { + *slot = Some(r); + } + } + } + let status = |r: Option<&Value>| r.and_then(|r| r["status"].as_str()).unwrap_or("not run").to_string(); + let mut rows = Vec::new(); + let (mut counts, mut times, mut agree) = ([StatusCounts::new(), StatusCounts::new()], [0.0f64; 2], 0); + for ((area, case), runs) in &pairs { + let same = status(runs[0]) == status(runs[1]); + agree += usize::from(same); + let mut row = json!({"case": case, "area": area, "agree": same}); + for (k, name) in BACKENDS.iter().enumerate() { + let r = runs[k]; + *counts[k].entry(bucket(&status(r)).to_string()).or_default() += 1; + times[k] += r.and_then(seconds).unwrap_or(0.0); + let error = r.map(|r| notes(r).join("; ")).filter(|e| !e.is_empty()); + row[*name] = json!({"status": status(r), "seconds": r.and_then(seconds), "error": error}); + } + rows.push(row); + } + let differ = pairs.len() - agree; + let mut md = String::from( + "# Kernel regression rig: SMLib and solidcore\n\nGenerated by `cargo xtask kernel-cases --kernel both`: every case in [`tests/kernel-cases/`](../tests/kernel-cases/) run on each backend (`SOLIDCRAFT_KERNEL`), graded against its own answer. Times are the commands' own. [`kernel-cases.md`](kernel-cases.md) is the SMLib run in full.\n\n", + ); + md += "## Summary\n\n| Backend | Pass | Fail | Other | Time (s) |\n|---|---|---|---|---|\n"; + for (k, name) in BACKENDS.iter().enumerate() { + let n = |b: &str| counts[k].get(b).copied().unwrap_or(0); + md += &format!("| {name} | {} | {} | {} | {:.1} |\n", n("pass"), n("fail"), n("other"), times[k]); + } + md += &format!("\n{} cases: {agree} agree, {differ} differ.\n", pairs.len()); + let side = |r: &Value| match r["seconds"].as_f64() { + Some(s) => format!("{} ({s:.2} s)", r["status"].as_str().unwrap_or("?")), + None => r["status"].as_str().unwrap_or("?").to_string(), + }; + let mut by_area: BTreeMap<&str, Vec<&Value>> = BTreeMap::new(); + for row in &rows { + by_area.entry(row["area"].as_str().unwrap_or("")).or_default().push(row); + } + for (area, mut list) in by_area { + list.sort_by_key(|r| (r["agree"].as_bool().unwrap_or(false), r["case"].as_str().unwrap_or("").to_string())); + md += &format!("\n## {area}\n\n| Case | {} | {} | |\n|---|---|---|---|\n", BACKENDS[0], BACKENDS[1]); + for r in list { + let mark = if r["agree"].as_bool().unwrap_or(false) { "" } else { "**differ**" }; + md += &format!("| {} | {} | {} | {mark} |\n", r["case"].as_str().unwrap_or(""), side(&r["smlib"]), side(&r["solidcore"])); + } + } + let summary: Value = BACKENDS + .iter() + .enumerate() + .map(|(k, name)| (name.to_string(), json!({"counts": counts[k], "seconds": times[k]}))) + .collect::>() + .into(); + (json!({"summary": summary, "agree": agree, "differ": differ, "cases": rows}), md) } pub fn run(rest: &[&str]) -> Result<(), String> { if rest.contains(&"--ladder") { + if options(rest, "target/kernel-ladder")?.kernels.len() != 1 { + return Err("--kernel: the ladder runs on one backend, smlib or solidcore".into()); + } return crate::kernel_ladder::run(rest); } let o = options(rest, "target/kernel-cases")?; @@ -354,18 +539,39 @@ pub fn run(rest: &[&str]) -> Result<(), String> { if list.is_empty() { return Err(format!("no cases (recipe.json + expected.json) under {}", dir.display())); } - let results = run_all(&o, &list); - save_results(&o, &results)?; + let runs = run_on(&o, &list, &o.kernels); + for (kernel, results) in o.kernels.iter().zip(&runs) { + save_json(&o.out_of(kernel), "results.json", results)?; + } if let Some(file) = &o.known_failing { - // A gate run grades one set against its baseline; the report stays the full run's. + // A gate run grades the run's one kernel against its baseline, and writes no report; its + // results.json is still saved above. let text = std::fs::read_to_string(file).map_err(|e| format!("{}: {e}", file.display()))?; let base = file.parent().unwrap_or(&dir); let paths: Vec = list.iter().map(|c| c.strip_prefix(base).unwrap_or(c).to_string_lossy().replace('\\', "/")).collect(); - let statuses: Vec<&str> = results.iter().map(|r| r["status"].as_str().unwrap_or("?")).collect(); + let graded = runs.first().ok_or("--known-failing: no run to grade")?; + let statuses: Vec<&str> = graded.iter().map(|r| r["status"].as_str().unwrap_or("?")).collect(); println!("{}", gate(&text, &paths, &statuses).map_err(|e| format!("{}: {e}", file.display()))?); return Ok(()); } - println!("{}", write_report(&dir, &list, &results)?); + for (kernel, results) in o.kernels.iter().zip(&runs) { + let out = o.out_of(kernel); + let to = if *kernel == "smlib" { PathBuf::from("docs/kernel-cases.md") } else { out.join("report.md") }; + let env = format!("SOLIDCRAFT_KERNEL={kernel}"); + println!("{}", write_report(&dir, &list, results, kernel, &env, &to)?); + } + if let [smlib, solidcore] = runs.as_slice() { + let (data, md) = side_by_side(smlib, solidcore); + let into = o.out.clone(); + save_json(&into, "backends.json", &data)?; + std::fs::write(root().join("docs/kernel-cases-backends.md"), md).map_err(|e| format!("docs/kernel-cases-backends.md: {e}"))?; + println!( + "kernel-cases: {} agree, {} differ → docs/kernel-cases-backends.md, {}", + data["agree"], + data["differ"], + into.join("backends.json").display() + ); + } Ok(()) } @@ -563,9 +769,91 @@ fn run_coverage(o: &Options, dir: &Path) -> Result { #[cfg(test)] mod tests { - use super::{Exempt, coverage, exempt, gate, recipe_commands}; + use super::*; - fn run(text: &str, cases: &[(&str, &str)]) -> Result { + /// A child that writes more than the pipe buffer still finishes, with all its output. + #[cfg(unix)] + #[test] + fn a_child_writing_more_than_the_pipe_buffer_is_not_a_hang() { + let child = Command::new("sh") + .args(["-c", "head -c 200000 /dev/zero; head -c 100000 /dev/zero >&2"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let out = wait_draining(child, Duration::from_secs(60)).unwrap().unwrap(); + assert!(out.status.success()); + assert_eq!((out.stdout.len(), out.stderr.len()), (200_000, 100_000)); + let slow = Command::new("sh").args(["-c", "sleep 30"]).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let t = Instant::now(); + assert!(wait_draining(slow, Duration::from_millis(200)).unwrap().is_none()); + assert!(t.elapsed() < Duration::from_secs(20)); + } + + #[test] + fn side_by_side_counts_and_puts_differences_first() { + let r = |area: &str, case: &str, status: &str, s: f64| json!({"area": area, "case": case, "status": status, "seconds": s}); + let smlib = [r("booleans", "a", "pass", 1.0), r("booleans", "b", "pass", 2.0), r("shell", "c", "crash", 0.5)]; + let mut hang = json!({"area": "booleans", "case": "b", "status": "hang", "error": "no result after 180 s"}); + hang["process_seconds"] = json!(180.0); + let solidcore = [r("booleans", "a", "pass", 0.5), hang, r("shell", "c", "fail", 1.5)]; + let (data, md) = side_by_side(&smlib, &solidcore); + assert_eq!((data["agree"].as_u64(), data["differ"].as_u64()), (Some(1), Some(2))); + assert_eq!(data["summary"]["smlib"]["counts"], json!({"pass": 2, "other": 1})); + assert_eq!(data["summary"]["solidcore"]["counts"], json!({"pass": 1, "fail": 1, "other": 1})); + assert_eq!(data["summary"]["solidcore"]["seconds"].as_f64(), Some(182.0)); + let b = data["cases"].as_array().and_then(|c| c.iter().find(|c| c["case"] == "b")).cloned().unwrap_or_default(); + assert_eq!((b["solidcore"]["status"].as_str(), b["solidcore"]["error"].as_str()), (Some("hang"), Some("no result after 180 s"))); + // In booleans, b (differs) comes before a (agrees). + let (pos_a, pos_b) = (md.find("| a |"), md.find("| b |")); + assert!(pos_b.is_some() && pos_a.is_some() && pos_b < pos_a, "{md}"); + assert!(md.contains("| b | pass (2.00 s) | hang (180.00 s) | **differ** |"), "{md}"); + assert!(md.contains("3 cases: 1 agree, 2 differ."), "{md}"); + } + + /// `--route`, the old per-family run, is no option: each case's process has one kernel. Passed + /// anyway, it is refused rather than run as something else. + #[test] + fn a_kernel_has_its_own_folder_and_route_is_refused() { + let o = options(&["--kernel", "both", "--out", "/r"], "x").unwrap(); + assert_eq!((o.out_of("smlib"), o.out_of("solidcore")), (PathBuf::from("/r"), PathBuf::from("/r/solidcore"))); + assert!(options(&["--route", "boolean=solidcore"], "x").is_err()); + assert!(options(&["--kernel", "both", "--route"], "x").is_err()); + } + + /// `--known-failing` follows the kernel given, solidcore by default: a list is one kernel's. + #[test] + fn the_gate_grades_the_kernel_given() { + for (args, kernel) in [ + (&["--known-failing", "/k"][..], "solidcore"), + (&["--kernel", "solidcore", "--known-failing", "/k"], "solidcore"), + (&["--kernel", "smlib", "--known-failing", "/k"], "smlib"), + ] { + let o = options(args, "x").unwrap(); + assert_eq!((o.known_failing, o.kernels), (Some(PathBuf::from("/k")), vec![kernel]), "{args:?}"); + } + assert!(options(&["--kernel", "both", "--known-failing", "/k"], "x").is_err()); + } + + /// With no `--kernel`, a run is on solidcore; the C++ kernel is asked for. + #[test] + fn the_rig_defaults_to_solidcore() { + assert_eq!(options(&[], "x").unwrap().kernels, ["solidcore"]); + assert_eq!(options(&["--kernel", "smlib"], "x").unwrap().kernels, ["smlib"]); + assert_eq!(options(&["--kernel", "both"], "x").unwrap().kernels, ["smlib", "solidcore"]); + } + + /// Only a run that asks for the C++ kernel builds the CLI with it. + #[test] + fn the_cli_is_built_with_the_cpp_kernel_only_when_a_run_asks_for_it() { + let features = |args: &[&str]| cli_features(&options(args, "x").unwrap().kernels).to_vec(); + assert!(features(&[]).is_empty()); + assert!(features(&["--kernel", "solidcore"]).is_empty()); + assert_eq!(features(&["--kernel", "smlib"]), ["--features", "smlib"]); + assert_eq!(features(&["--kernel", "both"]), ["--features", "smlib"]); + } + + fn gate_on(text: &str, cases: &[(&str, &str)]) -> Result { let names: Vec = cases.iter().map(|(c, _)| c.to_string()).collect(); let statuses: Vec<&str> = cases.iter().map(|(_, s)| *s).collect(); gate(text, &names, &statuses) @@ -574,20 +862,20 @@ mod tests { #[test] fn the_gate_fails_on_a_regression_and_on_a_fixed_case_still_listed() { let list = "# today\na-case SMLib refuses the corner\nb-case G1 at the corner point\n"; - assert!(run(list, &[("a-case", "fail"), ("b-case", "crash"), ("c-case", "pass")]).is_ok()); - let regression = run(list, &[("a-case", "fail"), ("b-case", "fail"), ("c-case", "hang")]).unwrap_err(); + assert!(gate_on(list, &[("a-case", "fail"), ("b-case", "crash"), ("c-case", "pass")]).is_ok()); + let regression = gate_on(list, &[("a-case", "fail"), ("b-case", "fail"), ("c-case", "hang")]).unwrap_err(); assert!(regression.contains("REGRESSION (hang): c-case"), "{regression}"); - let fixed = run(list, &[("a-case", "pass"), ("b-case", "fail"), ("c-case", "pass")]).unwrap_err(); + let fixed = gate_on(list, &[("a-case", "pass"), ("b-case", "fail"), ("c-case", "pass")]).unwrap_err(); assert!(fixed.contains("FIXED, remove it from the known-failing list: a-case"), "{fixed}"); - let stale = run(list, &[("a-case", "fail"), ("c-case", "pass")]).unwrap_err(); + let stale = gate_on(list, &[("a-case", "fail"), ("c-case", "pass")]).unwrap_err(); assert!(stale.contains("not a case of this set: b-case"), "{stale}"); } #[test] fn the_known_failing_list_is_sorted_with_a_cause_each() { - assert!(run("b x\na y\n", &[("a", "fail"), ("b", "fail")]).unwrap_err().contains("out of order")); - assert!(run("a x\na y\n", &[("a", "fail")]).unwrap_err().contains("a repeat of")); - assert!(run("a\n", &[("a", "fail")]).unwrap_err().contains("gives no cause")); + assert!(gate_on("b x\na y\n", &[("a", "fail"), ("b", "fail")]).unwrap_err().contains("out of order")); + assert!(gate_on("a x\na y\n", &[("a", "fail")]).unwrap_err().contains("a repeat of")); + assert!(gate_on("a\n", &[("a", "fail")]).unwrap_err().contains("gives no cause")); } #[test] diff --git a/xtask/src/kernel_ladder.rs b/xtask/src/kernel_ladder.rs index 978b904871..840b4db254 100644 --- a/xtask/src/kernel_ladder.rs +++ b/xtask/src/kernel_ladder.rs @@ -3,7 +3,7 @@ //! `tests/kernel-cases/ladder/ladder.json` lists tiers in order of complexity and, per tier, its //! cases in order (paths under `tests/kernel-cases/`, so a tier may also list rig cases from other //! areas). Each case is a test like any rig case, graded against its own `expected.json`, on -//! SMLib. On top, `tests/kernel-cases/ladder/golden.json` records per case the status, the +//! `--kernel`'s one backend. The golden is SMLib's, so a gate or a record says `--kernel smlib`. On top, `tests/kernel-cases/ladder/golden.json` records per case the status, the //! commands' time and what was made (counts, volume, area): the run compares with it and flags //! slowdowns and changes; cases run one at a time unless `--jobs` says otherwise, so the times are //! not shared with other cases. `--record REASON` re-measures the golden and re-sorts each tier diff --git a/xtask/src/known_failing.rs b/xtask/src/known_failing.rs index b85b3a0c93..5a213cae1e 100644 --- a/xtask/src/known_failing.rs +++ b/xtask/src/known_failing.rs @@ -1,6 +1,7 @@ //! `cargo xtask known-failing --junit PATH... [--list FILE]`: grades a nextest run (its junit -//! report) against the tests known to fail today, `tests/KNOWN_FAILING_TESTS`. CI's test jobs -//! run it after nextest, in place of failing on any failed test. It fails on a test that failed +//! report) against the tests known to fail today on the default kernel, +//! `tests/KNOWN_FAILING_TESTS` (the C++ kernel's are `tests/KNOWN_FAILING_TESTS.smlib`, for a run +//! with `SOLIDCRAFT_KERNEL=smlib` and `--list`). CI's test jobs run it after nextest, in place of failing on any failed test. It fails on a test that failed //! and is not listed, and on a listed test that passed (fixed: its line comes off in the same //! pull request); a listed test that did not run (a pull request tests only the packages it //! touches) is neither. The list has the kernel rig's format (`kernel_cases::known_failing`): @@ -15,6 +16,8 @@ use quick_xml::Reader; use quick_xml::events::{BytesStart, Event}; pub const LIST: &str = "tests/KNOWN_FAILING_TESTS"; +/// The same for the C++ kernel (`SOLIDCRAFT_KERNEL=smlib`), graded with `--list`. +pub const SMLIB_LIST: &str = "tests/KNOWN_FAILING_TESTS.smlib"; pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> { let mut reports = Vec::new(); @@ -60,9 +63,12 @@ fn add(all: &mut BTreeMap, xml: &str) -> Result<(), String> { /// `cargo xtask lists`: the list is sorted, each test once, with a cause each. pub fn check_list(root: &Path) -> Result<(), String> { - let file = root.join(LIST); - let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; - crate::kernel_cases::known_failing(&text).map(|_| ()).map_err(|e| format!("{LIST}: {e}")) + for list in [LIST, SMLIB_LIST] { + let file = root.join(list); + let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; + crate::kernel_cases::known_failing(&text).map_err(|e| format!("{list}: {e}"))?; + } + Ok(()) } /// `path` itself, or every `.xml` file under it (sorted), into `out`. diff --git a/xtask/src/lists.rs b/xtask/src/lists.rs index 920c8dfe58..4e5588cce8 100644 --- a/xtask/src/lists.rs +++ b/xtask/src/lists.rs @@ -5,7 +5,7 @@ //! //! - The message catalogs (`assets/locales/*.json`): one message per line, sorted by id, no //! duplicate ids, as `serde_json` pretty-prints them. -//! - `tests/KNOWN_FAILING_TESTS`: one test a line with its cause, sorted, each test once +//! - `tests/KNOWN_FAILING_TESTS` and `.smlib`: one test a line with its cause, sorted, each test once //! (checked only; `cargo xtask known-failing` reads it). //! - Growing Rust lists: no written array lengths on more than ten lines of entries. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index d1438bf2c4..949c2e1b32 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -49,21 +49,29 @@ commands: replay the reference tests (tests/oracle/*/recipe.json), compare with each case's expected.json and write docs/oracle.md (--cli: a solidcraft-cli already built, instead of cargo run --release) - kernel-cases [--area AREA] [--cli BIN] [--timeout S] [--jobs N] [--out DIR] [--known-failing FILE] [--validation MODE] [--coverage] + kernel-cases [--area AREA] [--cli BIN] [--timeout S] [--jobs N] [--out DIR] [--kernel K] [--route R] [--known-failing FILE] [--validation MODE] [--coverage] the kernel regression rig: run every case in tests/kernel-cases/// on - SMLib (one process each, killed after --timeout, default 180 s), grade it against - the case's own answer and write docs/kernel-cases.md; DIR (default - target/kernel-cases) gets results.json and dumps/-/, the inputs of - each failing SMLib call (--cli: a solidcraft-cli built with --features smlib) + solidcore (one process each, killed after --timeout, default 180 s), grade it against + the case's own answer; DIR (default target/kernel-cases) gets results.json and + dumps/-/, the inputs of each failing SMLib call (--cli: a + solidcraft-cli already built); + --kernel solidcore|smlib|both (default solidcore) sets SOLIDCRAFT_KERNEL per case: + solidcore keeps results.json, dumps/ and report.md in DIR/solidcore/; smlib + writes docs/kernel-cases.md; both runs each case on each, and also writes the + side by side to docs/kernel-cases-backends.md and DIR/backends.json; smlib and + both need the C++ kernel, so without --cli they build the CLI with --features smlib + (a --cli built without it reports every case as unsupported) --known-failing FILE: a regression gate instead of the report: fails on a case that fails and FILE does not list, and on a listed case that passes - (tests/kernel-cases//KNOWN_FAILING, CI's Kernel rig workflow) + (tests/kernel-cases//KNOWN_FAILING, CI's Kernel rig workflow); it grades + the run's kernel, so a list is one kernel's (KNOWN_FAILING.solidcore, + KNOWN_FAILING for --kernel smlib) and --kernel both is refused --validation MODE: when SMLib's full check runs on the cases' features: each_step, each_feature (the default) or off (solidcraft-cli kernel-cases --validation=MODE); with --ladder too --ladder [--record REASON | --gate]: the complexity ladder instead (tests/kernel-cases/ladder/ladder.json, tiers in order), graded and compared - with its timing golden (one case at a time unless --jobs), into + with its timing golden (one case at a time unless --jobs), on --kernel's one backend, into docs/kernel-ladder.md (DIR default target/kernel-ladder); --record re-measures the golden and re-sorts each tier by time; --gate fails on any case whose status or counts differ from the golden, instead of the report @@ -72,7 +80,8 @@ commands: uses and tests/kernel-cases/EXEMPT does not list, and on a stale EXEMPT line known-failing --junit PATH... [--list FILE] grade a nextest run's junit report (a file, or every .xml under a directory) - against the tests known to fail today, tests/KNOWN_FAILING_TESTS (FILE): fails + against the tests known to fail today on the default kernel, + tests/KNOWN_FAILING_TESTS (FILE: .smlib is the C++ kernel's): fails on a test that failed and is not listed, and on a listed test that passed (fixed: take its line off); a listed test that did not run is neither. CI's test jobs run it after nextest