Compare commits

..

1 Commits

Author SHA1 Message Date
bherbruck 699eb837b5 Web app: stable, release candidate and edge builds at /app/, /app-rc/ and /app-edge/
The site now serves the web app in three channels. /app/ is built from the latest release
instead of main, /app-rc/ from the newest release candidate and /app-edge/ from main. Each
pre-release channel keeps its designs, settings and recovery copies apart from the release's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:47:00 -05:00
202 changed files with 6237 additions and 17109 deletions
+35
View File
@@ -442,6 +442,9 @@ 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
@@ -461,6 +464,9 @@ 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 }}
@@ -504,6 +510,9 @@ 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:
@@ -575,6 +584,9 @@ 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:
@@ -691,6 +703,9 @@ 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:
@@ -847,6 +862,9 @@ 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:
@@ -905,6 +923,9 @@ 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:
@@ -944,6 +965,9 @@ 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
@@ -968,6 +992,9 @@ 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
@@ -1105,6 +1132,9 @@ 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:
@@ -1127,6 +1157,9 @@ 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.
@@ -1147,6 +1180,8 @@ jobs:
- name: Cargo check on RISC-V
env:
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
# SMLib has no prebuilt library for RISC-V: its sources are compiled with this.
CXX_riscv64gc_unknown_linux_gnu: riscv64-linux-gnu-g++
PKG_CONFIG_SYSROOT_DIR: /
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
PKG_CONFIG_ALLOW_CROSS: 1
+3
View File
@@ -53,6 +53,9 @@ jobs:
with:
key: docker-web
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
- name: Install trunk
run: |
mkdir -p "$HOME/.local/bin"
+3
View File
@@ -29,6 +29,9 @@ 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
+8 -3
View File
@@ -1,5 +1,5 @@
# 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/ through one release build of
# runs every case in tests/kernel-cases/fillets/ on SMLib, 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
# (it is fixed: remove its line, so the list only shrinks). See tests/kernel-cases/README.md.
@@ -25,6 +25,7 @@ on:
# The rig itself: the grading, the runner and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "scripts/fetch-smlib.sh"
- ".github/workflows/fillet-rig.yml"
push:
branches: [main, develop]
@@ -41,6 +42,7 @@ on:
# The rig itself: the grading, the runner and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "scripts/fetch-smlib.sh"
- ".github/workflows/fillet-rig.yml"
workflow_dispatch:
@@ -84,12 +86,15 @@ 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)
run: cargo build --release --locked -p solidcraft-cli
- name: Build the CLI (release, SMLib)
run: cargo build --release --locked -p solidcraft-cli --features smlib
- name: Run the fillet cases against the known-failing list
run: >-
cargo run -q --locked -p xtask -- kernel-cases --area fillets
+3
View File
@@ -48,6 +48,9 @@ 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
+11 -15
View File
@@ -1,9 +1,8 @@
# The kernel rig and the complexity ladder as a regression gate. A plan job maps a pull request's
# changed files to the rig areas they can affect (scripts/ci/kernel_rig_plan.py): an area runs
# when a file its tests/kernel-cases/<area>/PATHS lists changes, or one of its cases; every area
# runs when the shared core in tests/kernel-cases/PATHS-ALL changes, and when the solidcore pin in
# the root Cargo.toml and Cargo.lock moves (the plan reads their diff); the ladder runs whenever any
# area does. One job then builds solidcraft-cli once (release), checks that every
# runs when the shared core in tests/kernel-cases/PATHS-ALL changes; the ladder runs whenever any
# area does. One job then builds solidcraft-cli once (release, SMLib prebuilt), checks that every
# registry command has a case or an exemption (tests/kernel-cases/EXEMPT), and hands the CLI to a
# 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.
@@ -35,13 +34,11 @@ on:
- "crates/doc/src/**"
- "crates/engine/src/**"
- "tests/kernel-cases/**"
# The solidcore pin (the plan runs every area only when its lines change).
- "Cargo.toml"
- "Cargo.lock"
# The rig itself: the grading, the runner, the selection and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "xtask/src/kernel_ladder.rs"
- "scripts/fetch-smlib.sh"
- "scripts/ci/kernel_rig_plan.py"
- "scripts/ci/test_kernel_rig_plan.py"
- ".github/workflows/kernel-rig.yml"
@@ -57,13 +54,11 @@ on:
- "crates/doc/src/**"
- "crates/engine/src/**"
- "tests/kernel-cases/**"
# The solidcore pin (the plan runs every area only when its lines change).
- "Cargo.toml"
- "Cargo.lock"
# The rig itself: the grading, the runner, the selection and this workflow.
- "apps/solidcraft-cli/src/kernel_cases.rs"
- "xtask/src/kernel_cases.rs"
- "xtask/src/kernel_ladder.rs"
- "scripts/fetch-smlib.sh"
- "scripts/ci/kernel_rig_plan.py"
- "scripts/ci/test_kernel_rig_plan.py"
- ".github/workflows/kernel-rig.yml"
@@ -112,9 +107,7 @@ jobs:
# gate and a run by hand run every area.
if [ "$EVENT_NAME" = pull_request ]; then
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/rig-paths"
git diff --no-renames "$BASE_SHA...$HEAD_SHA" -- Cargo.toml Cargo.lock > "$RUNNER_TEMP/rig-pin.diff"
python3 -I scripts/ci/kernel_rig_plan.py --paths "$RUNNER_TEMP/rig-paths" --pin-diff "$RUNNER_TEMP/rig-pin.diff" \
--github-output "$GITHUB_OUTPUT"
python3 -I scripts/ci/kernel_rig_plan.py --paths "$RUNNER_TEMP/rig-paths" --github-output "$GITHUB_OUTPUT"
else
python3 -I scripts/ci/kernel_rig_plan.py --all --github-output "$GITHUB_OUTPUT"
fi
@@ -148,13 +141,16 @@ jobs:
with:
key: kernel-rig
save-if: ${{ github.event_name == 'push' && (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) and the rig's runner
- name: Build the CLI (release, SMLib) and the rig's runner
run: |
cargo build --release --locked -p solidcraft-cli
cargo build --release --locked -p solidcraft-cli --features smlib
cargo build --locked -p xtask
# A tar keeps the executable bits, which an artifact drops. xtask finds the repository from
# the path it was built in (CARGO_MANIFEST_DIR), which is the same checkout path in every
@@ -213,7 +209,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.
+9 -2
View File
@@ -1,5 +1,6 @@
# The documentation site on GitHub Pages: the book (docs/book) at https://docs.solidcraft.io/
# and the bundled web app at /app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# and the web app at /app/ (the latest release), /app-rc/ (the newest release candidate) and
# /app-edge/ (this commit). Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
@@ -41,6 +42,9 @@ 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"
@@ -66,7 +70,10 @@ jobs:
mkdir -p site
cp -r target/book/. site/
bash docs/book/seo.sh site # canonical links, robots.txt, sitemap.xml
cp -r dist/web site/app
- name: Add the web app channels (stable and release candidate from their releases, edge from this build)
env:
GH_TOKEN: ${{ github.token }}
run: bash packaging/web-channels.sh site dist/web
- uses: actions/configure-pages@v5
id: pages
- name: Install Flatpak repository tools
+16 -22
View File
@@ -126,6 +126,9 @@ 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
@@ -165,6 +168,9 @@ 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
@@ -234,8 +240,6 @@ jobs:
if: needs.version.outputs.build == 'true'
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
runs-on: ubuntu-24.04
# A pre-release or nightly without an Arch package is still worth having; a stable release isn't.
continue-on-error: ${{ contains(needs.version.outputs.version, '-') }}
strategy:
fail-fast: false
matrix:
@@ -252,29 +256,10 @@ jobs:
# makepkg in an archlinux:base-devel container. The package is named after the tarball's
# .desktop file (io.tangencylabs.solidcraft) and written as
# dist/release/solidcraft-<version>-linux-<arch>.pkg.tar.zst.
# An Arch version can't hold a hyphen, and the action reads the version from the tarball's name
# (a hyphenated one comes out as 0.0.0): 0.40.0-rc.1 goes in as 0.40.0rc.1, which pacman sorts
# before 0.40.0, and the package keeps the release's name.
- id: pkgver
name: Arch version
run: |
pkgver="${VERSION//-/}"
echo "pkgver=$pkgver" >>"$GITHUB_OUTPUT"
if [ "$pkgver" != "$VERSION" ]; then
cp "dist/linux/solidcraft-$VERSION-linux-$ARCH.tar.gz" "dist/linux/solidcraft-$pkgver-linux-$ARCH.tar.gz"
fi
env:
ARCH: ${{ matrix.arch }}
- uses: storytold/craft-common-actions/actions/arch-package@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5 # main
with:
tarball: dist/linux/solidcraft-${{ steps.pkgver.outputs.pkgver }}-linux-${{ matrix.arch }}.tar.gz
tarball: dist/linux/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.tar.gz
arch: ${{ matrix.arch }}
- name: The package under the release's version
if: steps.pkgver.outputs.pkgver != env.VERSION
env:
ARCH: ${{ matrix.arch }}
PKGVER: ${{ steps.pkgver.outputs.pkgver }}
run: sudo mv "dist/release/solidcraft-$PKGVER-linux-$ARCH.pkg.tar.zst" "dist/release/solidcraft-$VERSION-linux-$ARCH.pkg.tar.zst"
- name: Check the package
env:
PKG: dist/release/solidcraft-${{ env.VERSION }}-linux-${{ matrix.arch }}.pkg.tar.zst
@@ -410,6 +395,9 @@ 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.
@@ -508,6 +496,9 @@ 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: |
@@ -639,6 +630,9 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-web
- name: Fetch SMLib (crates/smlib/SMLIB_REV)
shell: bash
run: scripts/fetch-smlib.sh wasm32-unknown-unknown
- name: Install trunk
run: |
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
-10
View File
@@ -811,15 +811,6 @@ 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 B-rep kernel behind crates/smlib | 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)
@@ -835,7 +826,6 @@ copied into SolidCraft.
| SolidCraft file | Original contribution | Licence |
|---|---|---|
| crates/ui-egui/src/viewport.rs (`nearest_quarter_turn`, `axis_view_orbit`) | N3MI-DG's [PR #101](https://github.com/bherbruck/solidcraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solidcraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solidcraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
| crates/smlib/src/core/offset.rs (`draft`: the tilted planes and `SmBrep::LocalOperation`, the tail of the capi's `draft_faces`) | ablasik's `draft` in `crates/smlib/src/ffi/rs_ops2.rs` on [ablasik/solidcraft `app/solidcore-backend-on-develop`](https://github.com/ablasik/solidcraft/blob/9c7e8e31565e1823f0591b58e2967351c32af13f/crates/smlib/src/ffi/rs_ops2.rs), adapted to `core::offset`'s errors and checks | MIT OR Apache-2.0 |
## Vendored third-party code
Generated
+23 -30
View File
@@ -4051,17 +4051,9 @@ dependencies = [
"serde",
]
[[package]]
name = "solidcore"
version = "1.0.1"
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "solidcraft"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4098,11 +4090,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.1"
version = "0.30.1"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"image",
"log",
@@ -4115,7 +4107,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"proc-macro2",
"quote",
@@ -4124,7 +4116,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"log",
"serde",
@@ -4142,7 +4134,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"image",
"log",
@@ -4164,7 +4156,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"serde",
"serde_json",
@@ -4173,7 +4165,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"image",
"log",
@@ -4193,7 +4185,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4219,7 +4211,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"log",
"proptest",
@@ -4237,7 +4229,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"image",
"serde_json",
@@ -4247,7 +4239,7 @@ dependencies = [
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"serde_json",
"web-time",
@@ -4255,7 +4247,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"image",
"moxcms",
@@ -4270,7 +4262,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"harfrust",
"serde",
@@ -4283,26 +4275,27 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"cc",
"flate2",
"log",
"serde_json",
"solidcore",
"solidcraft-geom",
"solidcraft-profile",
"web-time",
]
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-step"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"solidcraft-geom",
"solidcraft-profile",
@@ -4310,11 +4303,11 @@ dependencies = [
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.1"
version = "0.30.1"
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"arboard",
"egui",
@@ -4345,7 +4338,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"eframe",
"egui",
@@ -5752,7 +5745,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.1"
version = "0.30.1"
dependencies = [
"proc-macro2",
"quick-xml",
+1 -6
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.40.0-rc.1"
version = "0.30.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -31,11 +31,6 @@ 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 B-rep kernel behind crates/smlib. 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 = "8ddce901671f1cebd9214a31d21895d725e886c8" }
# test tooling: a dev-dependency only (xtask/src/layers.rs)
solidcraft-testkit = { path = "crates/testkit" }
+1 -245
View File
@@ -1,6 +1,6 @@
Third-party software in SolidCraft
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 536 crates below.
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 535 crates below.
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
@@ -378,7 +378,6 @@ smithay-client-toolkit 0.19.2 (MIT)
smithay-client-toolkit 0.20.0 (MIT)
smithay-clipboard 0.7.3 (MIT)
smol_str 0.2.2 (MIT OR Apache-2.0)
solidcore 1.0.1 (Apache-2.0)
spade 2.15.1 (MIT OR Apache-2.0)
spirv 0.4.0+sdk-1.4.341.0 (Apache-2.0)
stable_deref_trait 1.2.1 (MIT OR Apache-2.0)
@@ -12400,249 +12399,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
solidcore 1.0.1
================================================================================
Copyright (c) 2020-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2020-2026 NVIDIA CORPORATION & AFFILIATES
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
solidcore 1.0.1
================================================================================
solidcore
Copyright (c) 2026 The solidcore contributors
This product is a Rust port (a derivative work) of the SMLib and SM_API
components of USD BRep, https://github.com/NVIDIA-Omniverse/usd-brep
(v1.0.1, commit 3fd5b95), licensed under the Apache License, Version 2.0.
See ATTRIBUTION.md for exactly which upstream material is used.
The upstream work carries the following notice:
USD BRep is Copyright (c) 2020-2026, NVIDIA CORPORATION & AFFILIATES. All
rights reserved. NVIDIA-authored portions of this product are licensed under
the Apache License, Version 2.0, except where otherwise noted.
The upstream SMLib and SM_API source files carry the SPDX header:
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
Upstream third-party notices (THIRD_PARTY_NOTICES.md at the pinned commit) cover
OpenUSD, oneTBB, hwloc, MaterialX, OpenSubdiv, CPython, HACL*, pybind11, the
UsdSolid proposals, usd-validation-nvidia, the repo bootstrap scripts and optional
Python GUI tooling. None of those components is part of SMLib or SM_API, and none
is used, ported or redistributed by solidcore, so no further third-party notices
apply.
Modifications: the C++ sources were translated to Rust and restructured (arena
topology, typed ids, error values instead of status codes, explicit kernel
state instead of a global context). Each ported Rust file names the upstream
file(s) it derives from in its header.
================================================================================
spade 2.15.1
================================================================================
+4 -1
View File
@@ -20,9 +20,12 @@ name = "solvecraft-cli"
path = "src/alias.rs"
[features]
default = ["raytrace-cpu", "profile"]
default = ["raytrace-cpu", "smlib", "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.
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.
profile = ["solidcraft-engine/profile"]
+13 -6
View File
@@ -9,9 +9,9 @@ use serde_json::{Value, json};
use crate::oracle::{ABS_FRAC, Checks, REL_TOL, totals, v3};
use crate::{read_json, recipe};
/// The kernel the cases run on: solidcore, the only one.
/// The kernel the cases run on: SMLib, the only one.
fn kernel() -> String {
"solidcore".into()
"smlib".into()
}
/// How many cases a chain of `base` recipes may go through (a ladder case builds on the one
@@ -692,21 +692,28 @@ fn check_case(dir: &str, dumps: Option<&std::path::Path>, validation: solidcraft
return out;
}
};
if !solidcraft_engine::kernel::smlib_built() {
out["status"] = json!("unsupported");
out["error"] = json!("this build has no SMLib: build solidcraft-cli with --features smlib");
return out;
}
// `refused`: only a clean refusal is right; `solid_or_refused`: a clean refusal, or a result
// graded as usual (where the geometry has no manifold answer, such as bodies touching along a
// curve only).
let outcome = want["outcome"].as_str().unwrap_or("solid");
let refused = outcome == "refused";
let may_refuse = refused || outcome == "solid_or_refused";
// The kernel calls that fail in this case leave their inputs in its own folder.
// The SMLib calls that fail in this case leave their inputs in its own folder.
if let Some(root) = dumps {
let folder = root.join(format!("{area}-{name}"));
if let Err(e) = std::fs::create_dir_all(&folder) {
if let Err(e) = std::fs::create_dir_all(&folder)
.map_err(|e| e.to_string())
.and_then(|()| solidcraft_engine::kernel::smlib_dump_dir(Some(&folder)).map_err(|e| e.to_string()))
{
out["status"] = json!("fail");
out["error"] = json!(format!("--dumps: {}: {e}", folder.display()));
return out;
}
solidcraft_engine::kernel::smlib_dump_dir(Some(&folder));
}
let mut s = solidcraft_engine::Session::default();
s.set_validation(validation);
@@ -978,7 +985,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
};
let results: Vec<Value> = dirs.iter().map(|d| check_case(d, dumps.as_deref(), validation)).collect();
if dumps.is_some() {
solidcraft_engine::kernel::smlib_dump_dir(None);
solidcraft_engine::kernel::smlib_dump_dir(None).map_err(|e| format!("kernel-cases: --dumps: {e}"))?;
}
report(args, &results);
let bad = results.iter().filter(|r| matches!(r["status"].as_str(), Some("fail" | "crash"))).count();
+1 -1
View File
@@ -1,5 +1,5 @@
//! `--profile` and `--profile-out FILE` for `run`, `eval` and `bench-open` (and
//! `SOLIDCRAFT_PROFILE=1` for any command): record where the time goes, the kernel's calls to the
//! `SOLIDCRAFT_PROFILE=1` for any command): record where the time goes, SMLib's calls to the
//! commands, print the table to stderr when the command ends (stdout stays the command's JSON)
//! and write the spans as a Chrome trace for chrome://tracing or Perfetto.
+6 -2
View File
@@ -14,8 +14,12 @@ name = "solidcraft-web"
path = "src/main.rs"
[features]
default = ["profile"]
default = ["smlib", "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.
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.
profile = ["solidcraft-engine/profile"]
@@ -36,7 +40,7 @@ js-sys = "0.3"
serde_json = { workspace = true }
web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemGetDirectoryOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", "StorageEvent",
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
-9
View File
@@ -1,9 +0,0 @@
//! 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");
}
}
+2
View File
@@ -0,0 +1,2 @@
This build keeps its designs and settings apart per release channel (src/channel.rs).
packaging/web-channels.sh only offers a release candidate that has this file.
+1
View File
@@ -34,6 +34,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="icons/32x32/io.tangencylabs.solidcraft.png">
<link rel="apple-touch-icon" href="icons/256x256/io.tangencylabs.solidcraft.png">
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="channel-storage.txt" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/32x32/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/32x32" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/256x256/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/256x256" />
<link data-trunk rel="copy-file" href="../../assets/icons/app/linux/exports/hicolor/512x512/apps/io.tangencylabs.solidcraft.png" data-target-path="icons/512x512" />
+61
View File
@@ -0,0 +1,61 @@
//! Release channels. The site serves the stable web app at `/app/`, the release candidate at
//! `/app-rc/` and the bleeding-edge build at `/app-edge/`: one origin, so one set of browser
//! storage. A channel that kept its designs and settings in the same places could save a design
//! in a file format the stable app can't open, or overwrite stable's settings. So each
//! pre-release channel keeps everything under its own name, and stable (and any copy served from
//! another path: the release zip, `trunk serve`) keeps the names it has always used, so what
//! people have saved at `/app/` is untouched.
//!
//! The channel comes from the directory the app is served from, not from the build, so a build
//! taken from a release zip is as separate as one built on the spot.
/// The channel the app is served as, from the page's path: `"rc"`, `"edge"`, or `""` (stable).
pub fn of_path(pathname: &str) -> &'static str {
match pathname.trim_end_matches("index.html").trim_end_matches('/').rsplit('/').next() {
Some("app-rc") => "rc",
Some("app-edge") => "edge",
_ => "",
}
}
/// `name` as this channel stores it: unchanged for stable, `rc:name` and `edge:name` otherwise.
pub fn name_in(channel: &str, name: &str) -> String {
if channel.is_empty() { name.to_owned() } else { format!("{channel}:{name}") }
}
#[cfg(target_arch = "wasm32")]
pub fn current() -> &'static str {
web_sys::window().and_then(|w| w.location().pathname().ok()).map_or("", |p| of_path(&p))
}
/// A storage key (local storage, IndexedDB database name) of the running channel.
#[cfg(target_arch = "wasm32")]
pub fn key(name: &str) -> String {
name_in(current(), name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_follows_the_directory() {
assert_eq!(of_path("/app/"), "");
assert_eq!(of_path("/app/index.html"), "");
assert_eq!(of_path("/app-rc/"), "rc");
assert_eq!(of_path("/app-rc"), "rc");
assert_eq!(of_path("/app-rc/index.html"), "rc");
assert_eq!(of_path("/app-edge/"), "edge");
assert_eq!(of_path("/"), "");
assert_eq!(of_path("/solidcraft-web-0.30.1/"), "");
assert_eq!(of_path("/app/app-rc/"), "rc", "only the directory's own name counts");
assert_eq!(of_path("/app-rc/app/"), "");
}
#[test]
fn stable_keeps_its_names() {
assert_eq!(name_in("", "solidcraft.prefs"), "solidcraft.prefs");
assert_eq!(name_in("rc", "solidcraft.prefs"), "rc:solidcraft.prefs");
assert_ne!(name_in("rc", "x"), name_in("edge", "x"));
}
}
+1 -1
View File
@@ -467,7 +467,7 @@ mod web {
async fn open_db() -> Result<IdbDatabase, String> {
let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(err)?.ok_or("this browser has no IndexedDB")?;
let request: IdbOpenDbRequest = factory.open_with_u32(DB, 1).map_err(err)?;
let request: IdbOpenDbRequest = factory.open_with_u32(&crate::channel::key(DB), 1).map_err(err)?;
let target = request.clone();
let upgrade = Closure::once_into_js(move || {
if let Ok(db) = target.result().and_then(|r| r.dyn_into::<IdbDatabase>()) {
+21 -6
View File
@@ -16,7 +16,9 @@ use solidcraft_engine::io::vfs;
use crate::disk;
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
use wasm_bindgen_futures::{JsFuture, spawn_local};
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
use web_sys::{
FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWritableFileStream,
};
/// Where OPFS files appear to the engine.
pub const OPFS: &str = disk::OPFS_PREFIX;
@@ -111,7 +113,7 @@ thread_local! {
static LOCAL: Cell<bool> = const { Cell::new(false) };
}
/// Local storage keys of stored files.
/// Local storage keys of stored files (under the channel's name, see `channel.rs`).
const LOCAL_PREFIX: &str = "solidcraft.file.";
/// Local-storage files kept by earlier releases (compatibility shim, docs/compatibility.md):
/// still listed and opened; removing one removes either key.
@@ -169,7 +171,17 @@ fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
async fn root() -> Result<FileSystemDirectoryHandle, String> {
let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
let dir = dir.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "this browser has no private file storage (OPFS)".to_string())?;
// Stable keeps its files in the root, where they have always been; a pre-release channel has a folder of its own.
match crate::channel::current() {
"" => Ok(dir),
channel => {
let opts = FileSystemGetDirectoryOptions::new();
opts.set_create(true);
let sub = JsFuture::from(dir.get_directory_handle_with_options(&format!("channel-{channel}"), &opts)).await.map_err(err)?;
sub.dyn_into::<FileSystemDirectoryHandle>().map_err(|_| "not a folder".to_string())
}
}
}
pub(crate) async fn read_handle(h: &FileSystemFileHandle) -> Result<Vec<u8>, String> {
@@ -191,7 +203,7 @@ async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool)
async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
if local_only() {
return local()?
.set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
.set_item(&crate::channel::key(&format!("{LOCAL_PREFIX}{name}")), &encode(bytes))
.map_err(|e| format!("local storage is full or blocked: {}", err(e)));
}
let dir = root().await?;
@@ -207,7 +219,7 @@ async fn remove_file(name: &str) -> Result<(), String> {
if local_only() {
let st = local()?;
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
return st.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
return st.remove_item(&crate::channel::key(&format!("{LOCAL_PREFIX}{name}"))).map_err(err);
}
let dir = root().await?;
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
@@ -220,7 +232,10 @@ fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
let mut out = Vec::new();
for i in 0..st.length().unwrap_or(0).min(10_000) {
let Some(k) = st.key(i).ok().flatten() else { continue };
let Some(name) = k.strip_prefix(LOCAL_PREFIX).or_else(|| k.strip_prefix(LEGACY_LOCAL_PREFIX)) else { continue };
let own = crate::channel::key(LOCAL_PREFIX);
// The old names belong to stable; a pre-release channel has none.
let legacy = crate::channel::current().is_empty().then_some(LEGACY_LOCAL_PREFIX);
let Some(name) = k.strip_prefix(own.as_str()).or_else(|| legacy.and_then(|l| k.strip_prefix(l))) else { continue };
if let Some(v) = st.get_item(&k).ok().flatten() {
out.push((name.to_string(), decode(&v), 0.0));
}
+26 -15
View File
@@ -9,6 +9,8 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod channel;
#[cfg(any(target_arch = "wasm32", test))]
mod disk;
#[cfg(target_arch = "wasm32")]
mod easyprint;
@@ -152,8 +154,15 @@ impl PrefsWriter {
/// tab of the page when one tab writes). Other keys, and settings taken away (clearing the site's
/// data), leave this tab's settings as they are.
#[cfg(any(target_arch = "wasm32", test))]
fn settings_written(key: Option<&str>, value: Option<String>) -> Option<String> {
value.filter(|_| key == Some(solidcraft_ui_egui::prefs::STORAGE_KEY))
fn settings_written(own_key: &str, key: Option<&str>, value: Option<String>) -> Option<String> {
// Tabs of another channel write to the same storage, under their own key.
value.filter(|_| key == Some(own_key))
}
/// The settings key of `channel` (see [`channel`]).
#[cfg(target_arch = "wasm32")]
fn prefs_key() -> String {
channel::key(solidcraft_ui_egui::prefs::STORAGE_KEY)
}
#[cfg(target_arch = "wasm32")]
@@ -262,7 +271,7 @@ mod web {
fn hear_settings_from_other_tabs(window: &web_sys::Window, ctx: &egui::Context, inbox: PrefsInbox) {
let ctx = ctx.clone();
let on_storage = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::StorageEvent)>::new(move |e: web_sys::StorageEvent| {
if let Some(prefs) = crate::settings_written(e.key().as_deref(), e.new_value()) {
if let Some(prefs) = crate::settings_written(&crate::prefs_key(), e.key().as_deref(), e.new_value()) {
inbox.set(Some(prefs));
ctx.request_repaint();
}
@@ -335,7 +344,7 @@ mod web {
let prefs = self.persisted_prefs();
let Some(prefs) = self.prefs_writer.take(prefs) else { return };
let stored =
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(solidcraft_ui_egui::prefs::STORAGE_KEY, &prefs));
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).map(|s| s.set_item(&crate::prefs_key(), &prefs));
if !matches!(stored, Some(Ok(()))) {
log::warn!("settings: the browser's storage did not take them; they are saved again later");
self.prefs_writer = crate::PrefsWriter::default();
@@ -834,14 +843,15 @@ mod web {
self.store_prefs(&ctx);
}
fn persist_egui_memory(&self) -> bool {
self.launch.persists()
// eframe keeps this under one fixed key: only stable uses it, so a pre-release never rewrites it.
self.launch.persists() && crate::channel::current().is_empty()
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
if !self.launch.persists() {
return;
}
let prefs = self.persisted_prefs();
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, prefs);
storage.set_string(&crate::prefs_key(), prefs);
}
}
@@ -851,7 +861,7 @@ mod web {
/// Where `?webgl` is remembered. The installed app starts from its manifest's start URL, which
/// has no query, so the choice has to outlive the page it was made on.
const WEBGL_KEY: &str = "solidcraft.webgl";
const WEBGL_KEY: &str = "solidcraft.webgl"; // stored under crate::channel::key
/// Render with WebGL2: asked for now (`?webgl`, remembered) or before (until `?webgpu`).
fn webgl() -> bool {
@@ -859,17 +869,17 @@ mod web {
let q = query();
if crate::query_flag(&q, "webgl") {
if let Some(s) = &storage {
let _ = s.set_item(WEBGL_KEY, "1");
let _ = s.set_item(&crate::channel::key(WEBGL_KEY), "1");
}
return true;
}
if crate::query_flag(&q, "webgpu") {
if let Some(s) = &storage {
let _ = s.remove_item(WEBGL_KEY);
let _ = s.remove_item(&crate::channel::key(WEBGL_KEY));
}
return false;
}
storage.and_then(|s| s.get_item(WEBGL_KEY).ok().flatten()).is_some()
storage.and_then(|s| s.get_item(&crate::channel::key(WEBGL_KEY)).ok().flatten()).is_some()
}
/// Fetch font bytes asynchronously through the browser.
@@ -968,7 +978,7 @@ mod web {
if launch.persists() {
hear_settings_from_other_tabs(&window, &cc.egui_ctx, other_tab_prefs.clone());
}
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
if launch.persists() && let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(&crate::channel::key(k)))) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
@@ -1171,9 +1181,10 @@ mod tests {
#[test]
fn only_settings_another_tab_wrote_are_loaded() {
let key = solidcraft_ui_egui::prefs::STORAGE_KEY;
assert_eq!(settings_written(Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(None, None), None, "clearing all storage leaves this tab's settings");
assert_eq!(settings_written(key, Some(key), Some("{}".into())).as_deref(), Some("{}"));
assert_eq!(settings_written(key, Some("solidcraft.webgl"), Some("1".into())), None);
assert_eq!(settings_written(key, Some(key), None), None, "removed settings leave this tab's");
assert_eq!(settings_written(key, None, None), None, "clearing all storage leaves this tab's settings");
assert_eq!(settings_written(key, Some("rc:solidcraft.prefs"), Some("{}".into())), None, "another channel's settings are not loaded");
}
}
+4 -1
View File
@@ -8,9 +8,12 @@ publish = false
default-run = "solidcraft"
[features]
default = ["raytrace-cpu", "bundled-render-library", "profile"]
default = ["raytrace-cpu", "bundled-render-library", "smlib", "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.
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.
profile = ["solidcraft-engine/profile"]
+4 -22
View File
@@ -309,7 +309,7 @@ impl ModelBody {
return None;
}
let body = self.clone();
let started = kernel::kernel_thread("plan-mesh").spawn(move || {
let started = std::thread::Builder::new().name("plan-mesh".into()).spawn(move || {
let _ = body.plan_mesh_at(q);
if let Ok(mut warming) = WARMING.lock() {
warming.retain(|k| *k != key);
@@ -444,12 +444,8 @@ fn with_largest_fit(
size: &str,
search: impl FnOnce() -> std::result::Result<Option<(f64, kernel::Body)>, kernel::KernelError>,
) -> kernel::KernelError {
// On solidcore the refusal names a size already, less a margin (34.99 where 35 builds), and
// its edge: the edge stays, the size is the largest the search builds, solidcore's where the
// search finds none.
match search() {
Ok(Some((largest, _))) if kernel::names_largest_fit(&why) => kernel::KernelError::TooLarge(kernel::with_fit_note(&why, size, largest)),
Ok(Some((largest, _))) => kernel::KernelError::TooLarge(format!("{why}; {}", kernel::largest_fit_note(size, largest))),
Ok(Some((largest, _))) => kernel::KernelError::TooLarge(format!("{why}; the largest {size} that fits is {} mm", show_mm(largest))),
Ok(None) => kernel::KernelError::TooLarge(why),
Err(e) => {
log::debug!("the largest {size} that fits: {e}");
@@ -1030,10 +1026,6 @@ pub struct FeatureResult {
pub id: u64,
pub name: String,
pub error: Option<String>,
/// `error` is the kernel's internal error (a panic it caught, [`kernel::KernelError::Internal`]),
/// not the feature's failure: the command that made the feature reports it as one.
#[serde(skip)]
pub internal: bool,
pub warning: Option<String>,
/// An Import feature's warnings by kind and part, with what came in of the file's parts
/// (`warning` is the first of them as the kernel words them).
@@ -2089,7 +2081,6 @@ impl Model {
id: f.id,
name: f.name.clone(),
error: None,
internal: false,
warning: None,
import_report: None,
repair: None,
@@ -2123,7 +2114,6 @@ impl Model {
id: f.id,
name: f.name.clone(),
error: Some(e),
internal: false,
warning: None,
import_report: None,
repair: None,
@@ -2162,7 +2152,6 @@ impl Model {
id: f.id,
name: f.name.clone(),
error: c.error,
internal: false,
warning: c.warning,
import_report: c.import_report,
repair: c.repair,
@@ -2231,11 +2220,7 @@ impl Model {
None => kernel::guard(&f.name, || {
let (r, deferred) =
kernel::validating(validation, || eval_feature(doc, &vals, f, &mut next, &mut warning, &mut import_report, &mut repair));
r.map_err(|e| match e {
// A panic the kernel caught stays one.
crate::DocError::Kernel(kernel::KernelError::Internal(m)) => kernel::KernelError::Internal(m),
e => kernel::KernelError::Failed(e.in_feature(&f.name).to_string()),
})?;
r.map_err(|e| kernel::KernelError::Failed(e.in_feature(&f.name).to_string()))?;
// The checks the feature's steps left to its end: the bodies it keeps.
if deferred { check_kept(&state, &next) } else { Ok(()) }
}),
@@ -2255,7 +2240,6 @@ impl Model {
note_join_apart(&state, &next, f, &mut warning);
}
let ms = t0.elapsed().as_secs_f64() * 1000.0;
let internal = matches!(r, Err(kernel::KernelError::Internal(_)));
let error = match r {
Ok(()) => {
// Copies are named after their sources through the instance transforms.
@@ -2299,8 +2283,7 @@ impl Model {
}
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
};
// A panic is no result to reuse: the kernel was replaced after it.
if !self.no_cache && !watched.cancelled() && !internal {
if !self.no_cache && !watched.cancelled() {
let e = CachedEval {
error: error.clone(),
warning: warning.clone(),
@@ -2322,7 +2305,6 @@ impl Model {
id: f.id,
name: f.name.clone(),
error,
internal,
warning,
import_report,
repair,
-1
View File
@@ -786,7 +786,6 @@ impl Model {
id: f.id,
name: f.name.clone(),
error: None,
internal: false,
warning: r.get("warning").map(|w| text(Some(w))).transpose()?,
import_report: r
.get("import_report")
+2
View File
@@ -13,6 +13,8 @@ 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).
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.
param-audit = []
+4 -2
View File
@@ -55,7 +55,7 @@ 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: solidcore (the kernel's calls), kernel, feature (one per feature \
self time first. Categories: smlib (SMLib's C API calls, timed by the C API), 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",
@@ -131,7 +131,9 @@ fn crash_test(_: &mut Session, p: &Value) -> Result<Value> {
match str_(p, "mode") {
Some("panic") => std::panic::panic_any(String::from("crash test: an internal error in a command")),
Some("kernel") => {
solidcraft_kernel::crash_test()?;
solidcraft_kernel::guard("crash test", || -> solidcraft_kernel::Result<()> {
std::panic::panic_any(String::from("an internal error in the kernel"))
})?;
Err(EngineError::Other("crash test: the kernel error was not caught".into()))
}
Some("abort") => std::process::abort(),
+2 -4
View File
@@ -826,10 +826,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
}
}
let r = s.model.result(id).cloned();
if let Some(r) = &r
&& let Some(e) = r.error.clone()
{
return Err(EngineError::of_feature(r, e));
if let Some(e) = r.as_ref().and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
+3 -2
View File
@@ -1124,7 +1124,8 @@ mod tests {
use super::*;
/// While the profiler records, a STEP import's reply carries its own profile: the import's
/// stages and the kernel's calls, as diagnostics.profile reports them.
/// stages and SMLib's calls, as diagnostics.profile reports them.
#[cfg(feature = "smlib")]
#[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);
@@ -1143,7 +1144,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("solidcore"), "{p}");
assert!(has("import") && has("smlib"), "{p}");
assert!(p["by_category"].as_array().is_some_and(|c| !c.is_empty()) && p["wall_ms"].as_f64().is_some_and(|w| w > 0.0), "{p}");
}
+1 -1
View File
@@ -324,7 +324,7 @@ fn run_ordered(jobs: Vec<Job>, budget: std::time::Duration) -> (Vec<Option<Done>
let mut threads = 0;
for _ in 0..want {
let (jobs, next, stop, tx) = (jobs.clone(), next.clone(), stop.clone(), tx.clone());
let spawned = solidcraft_kernel::kernel_thread("solidcraft-interference").spawn(move || {
let spawned = std::thread::Builder::new().name("solidcraft-interference".into()).spawn(move || {
while !stop.load(Ordering::Relaxed) {
let k = next.fetch_add(1, Ordering::Relaxed);
let Some(job) = jobs.get(k) else { break };
+1 -1
View File
@@ -34,7 +34,7 @@ mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
pub(crate) mod diagnostics;
mod diagnostics;
mod edit;
mod face;
mod features;
+1 -3
View File
@@ -168,10 +168,8 @@ fn child() {
}
assert_eq!(s.doc.features.len(), 1, "the design is unchanged");
}
// A panic inside a solidcore call: caught there, an internal error of the command.
"kernel" => {
let r = s.execute("diagnostics.crash_test", &json!({"mode": "kernel"}));
assert!(matches!(&r, Err(crate::EngineError::Internal(id, _)) if id == "diagnostics.crash_test"), "{r:?}");
assert!(s.execute("diagnostics.crash_test", &json!({"mode": "kernel"})).is_err());
}
// `thread_dies`: the panic ends the app, as one on the window's thread does.
"thread" | "thread_dies" => {
+4 -26
View File
@@ -84,10 +84,7 @@ pub enum EngineError {
impl From<solidcraft_doc::DocError> for EngineError {
fn from(e: solidcraft_doc::DocError) -> Self {
match e {
solidcraft_doc::DocError::Kernel(e) => e.into(),
e => EngineError::Other(e.to_string()),
}
EngineError::Other(e.to_string())
}
}
impl From<solidcraft_sketch::SketchError> for EngineError {
@@ -97,19 +94,7 @@ impl From<solidcraft_sketch::SketchError> for EngineError {
}
impl From<solidcraft_kernel::KernelError> for EngineError {
fn from(e: solidcraft_kernel::KernelError) -> Self {
match e {
// A panic the kernel caught: internal, as one in the command would be. The command's id
// is filled in where the command is run (`Session::execute`).
solidcraft_kernel::KernelError::Internal(m) => EngineError::Internal(String::new(), m),
e => EngineError::Other(e.to_string()),
}
}
}
impl EngineError {
/// The error a failed feature's result reports: internal when the kernel caught a panic.
pub(crate) fn of_feature(r: &solidcraft_doc::FeatureResult, error: String) -> EngineError {
if r.internal { EngineError::Internal(String::new(), error) } else { EngineError::Other(error) }
EngineError::Other(e.to_string())
}
}
impl From<solidcraft_io::IoError> for EngineError {
@@ -1663,11 +1648,6 @@ impl Session {
Err(EngineError::Internal(spec.id.to_string(), msg))
}
};
// The kernel's internal errors come up without the command's id.
let r = r.map_err(|e| match e {
EngineError::Internal(id, msg) if id.is_empty() => EngineError::Internal(spec.id.to_string(), msg),
e => e,
});
// A value typed as `name = expression` defines the user parameter `name` (#4071), in the
// same undo step; a refused definition fails the command, which leaves the design as it was.
let r = match r {
@@ -2194,10 +2174,8 @@ impl Session {
{
self.doc_mut().marker = Some(idx + 1);
self.refresh();
if let Some(r) = self.model.result(fid)
&& let Some(e) = r.error.clone()
{
return Err(EngineError::of_feature(r, e));
if let Some(e) = self.model.result(fid).and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ mod tests {
#[test]
fn the_licences_are_built_in() {
assert!(super::LICENSE_MIT.contains("MIT License") && super::LICENSE_APACHE.contains("Apache License"));
assert!(super::THIRD_PARTY.contains("wgpu") && super::THIRD_PARTY.contains("egui"));
assert!(super::THIRD_PARTY.contains("truck") && super::THIRD_PARTY.contains("egui"));
assert!(super::all().contains("NOTICE"));
}
}
+2 -1
View File
@@ -197,7 +197,8 @@ pub struct ProfileParams {
/// One category's share of the recorded self time.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ProfileCategory {
/// `solidcore` (the kernel's own calls), `kernel`, `feature`, `doc`, `command`, `import`, `io`.
/// `smlib` (SMLib's own time, measured by its C API), `kernel`, `feature`, `doc`, `command`,
/// `import`, `io`.
pub category: String,
pub self_ms: f64,
/// Of all recorded self time, 0–1.
+7 -8
View File
@@ -110,10 +110,9 @@ fn fillet_as_wide_as_a_face_rounds_it_off() {
/// The owner's ruling of 2026-10-30: two edges of a 20 mm box rounded r2 in one fillet (they
/// meet in a mitre at the corner (20, 0, 20), the upright edge there sharp), then the upright edge
/// r3 in a second fillet, whose tangent chain takes the mitre's seam with it. The corner is the
/// one solidcore 8ddce9016 builds for three rounds of different radii there: one patch, 10
/// faces, 7928.743041456134 mm³ (the fork's setback corner, 15 faces and 7928.142563938, waits
/// for solidcore#2310). The r2 rounds keep their names, and the r3 round and the corner's patch
/// are the second fillet's.
/// body rounding all three at once makes (kernel `fillet_radii` of the box with each edge's
/// radius, `three_rounds_at_a_corner`: SMLib's setback corner, 15 faces, 7928.142563938 mm³); the
/// r2 rounds keep their names, and the r3 round and the corner's patches are the second fillet's.
#[test]
fn a_later_round_at_a_mitred_corner_is_blended_with_the_rounds_there() {
let mut s = Session::default();
@@ -135,17 +134,17 @@ fn a_later_round_at_a_mitred_corner_is_blended_with_the_rounds_there() {
let second = run(&mut s, "solid.fillet", json!({"edges": [[20, 0, 10]], "radius": 3}))["feature"].as_u64().unwrap();
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let v = volume(&mut s);
assert!((v - 7928.743041456134).abs() < 1e-6, "{v}");
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
let faces = run(&mut s, "model.faces", json!({"body": "Box"}))["faces"].as_array().cloned().unwrap_or_default();
assert_eq!(faces.len(), 10);
assert_eq!(faces.len(), 15);
let after: Vec<String> = rounds.iter().map(|p| named(&mut s, *p)).collect();
assert_eq!(after, before);
// The r3 round (halfway up, at 45°) and the corner's patches: the faces the second fillet made.
let k3 = 3.0 - 4.5_f64.sqrt();
assert!(named(&mut s, solidcraft_geom::Vec3::new(20.0 - k3, k3, 10.0)).starts_with(&format!("F{second}:blend:")));
let made = faces.iter().filter(|f| f["name"].as_str().is_some_and(|n| n.starts_with(&format!("F{second}:")))).count();
// the r3 round and the corner's patch
assert_eq!(made, 2, "{faces:?}");
// the r3 round and the corner's six patches (a setback patch per round, three in the triangle)
assert_eq!(made, 7, "{faces:?}");
}
#[test]
-18
View File
@@ -45,24 +45,6 @@ fn chamfer_that_fits_still_builds() {
}
}
/// #199's bar, 14 x 6 x 10, its four upright edges rounded: past a full round of its 6 mm ends
/// the two rounds on each end would cross. Refused, naming 3 mm: the kernel's refusal carries a
/// size and the edge it was found for (`SolidError::FilletRefused`), and the message names the
/// edge.
#[test]
fn a_round_past_a_full_round_is_refused_naming_the_largest_that_fits_and_its_edge() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}));
let uprights = json!([[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]]);
for r in [3.001, 3.5] {
let e = s.execute("solid.fillet", &json!({"edges": uprights, "radius": r})).unwrap_err().to_string();
assert!(e.contains("the largest radius that fits is 3 mm"), "{r}: {e}");
assert!(e.contains("allow, at the edge at ("), "{r}: {e}");
assert_eq!(e.matches("that fits is").count(), 1, "{r}: {e}");
}
assert!((volume(&mut s) - 840.0).abs() < 1e-6, "the part stays as it was");
}
/// A rib 4 mm wide: blends of its two top edges meet in its top's middle at 2 mm and past that
/// would cross each other. Refused as too large, naming the largest size that fits.
#[test]
+1 -17
View File
@@ -109,25 +109,12 @@ fn clamped(p: usize, n: usize, k: &[f64]) -> bool {
p >= 1 && k.len() == n + p + 1 && ends(1..=p) && ends(n..=n + p - 1)
}
/// 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.
/// `x` moved by whole periods `b − a` into `[a, b]` when it lies outside.
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)
}
@@ -423,9 +410,6 @@ mod tests {
}
assert_eq!(s.wrap([1.0, 0.5]), [1.0, 0.5]);
assert_eq!(s.wrap([1.25, 2.0]), [0.25, 2.0]);
// A rounding past an end is that end, not the other one a period away.
assert_eq!(s.wrap([1.0 + f64::EPSILON, 0.5]), [1.0, 0.5]);
assert_eq!(s.wrap([-1e-17, 0.5]), [0.0, 0.5]);
// Projection finds a point near the seam from either side.
let p = s.derivs(0.02, 0.5).map(|d| d.0).unwrap_or(Vec3::ZERO);
let dom = domain(&s);
+3 -3
View File
@@ -26,9 +26,9 @@ wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] }
[dev-dependencies]
image = { workspace = true }
solidcraft-kernel = { workspace = true }
# Headless pictures of real designs (capture tests), built by the real kernel, solidcore, as in the
# apps.
solidcraft-engine = { workspace = true, features = ["bundled-render-library"] }
# 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"] }
solidcraft-io = { workspace = true }
serde_json = { workspace = true }
# The HLSL the D3D12 backend compiles (tests check it suits FXC).
+2 -13
View File
@@ -1215,7 +1215,7 @@ mod tests {
let tree: Value = serde_json::from_slice(manifest).unwrap();
assert_eq!(serde_json::to_vec(&tree).unwrap(), *manifest);
assert_eq!(tree["model"], model.records);
assert_eq!(tree.as_object().unwrap().len(), 12, "{:?}", tree.as_object().unwrap().keys().collect::<Vec<_>>());
assert_eq!(tree.as_object().unwrap().len(), 11, "{:?}", tree.as_object().unwrap().keys().collect::<Vec<_>>());
}
/// #9170: a mesh body (an imported STL) is kept in the model cache with the rest of the
@@ -1474,7 +1474,7 @@ mod tests {
let doc = document();
let bytes = write_design_container_with_cache(&doc, DesignCompression::Fast, None, Some(&native_geometry())).unwrap();
let original = parts(&bytes);
for key in ["format", "producer", "evaluation", "kernel", "document", "payload"] {
for key in ["format", "producer", "evaluation", "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,17 +1548,6 @@ 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 a model the C++ kernel computed (an earlier
// build's) is evaluated again (a cache without one is a miss too).
assert_eq!(written(&original)["kernel"], json!(native_cache::KERNEL));
let bytes = with(&[("kernel", "smlib")]);
assert!(read_design_geometry(&bytes).is_none(), "the C++ kernel's cache");
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]
+3 -11
View File
@@ -1,7 +1,7 @@
//! The model cache: a design's evaluated timeline (every feature's result and the state after
//! it, [`SavedModel`]), saved with it so opening need not evaluate it again. Bodies use
//! SolidCraft's exact native B-rep encoding (a mesh body, its own triangles); the key ties the
//! cache to the exact document, and to the source, platform and geometry kernel that computed it
//! cache to the exact document, and to the source and platform that computed it
//! (docs/design-container.md).
use super::*;
use solidcraft_doc::{ModelState, SavedModel};
@@ -29,11 +29,6 @@ fn evaluation() -> String {
format!("{}/{}-{}", env!("SOLIDCRAFT_EVALUATION_SOURCE"), std::env::consts::ARCH, std::env::consts::OS)
}
/// The geometry kernel that computed a cache. A cache another kernel wrote (the C++ SMLib, which
/// earlier builds could run on) is not this build's result: the kernels differ in tolerances and in
/// what they refuse, so a design opened from it evaluates again.
pub(super) const KERNEL: &str = "solidcore";
/// A 4 x 4 matrix as its 16 numbers, in storage order.
fn matrix(t: &[[f64; 4]; 4]) -> Vec<f64> {
t.iter().flatten().copied().collect()
@@ -301,7 +296,6 @@ impl Encoded {
document: digest(document),
evaluation: evaluation(),
format: FORMAT,
kernel: KERNEL,
model: records,
payload: digest(&data),
producer: PRODUCER,
@@ -326,7 +320,6 @@ struct Manifest<'a> {
document: String,
evaluation: String,
format: &'a str,
kernel: &'a str,
model: &'a Value,
payload: String,
producer: &'a str,
@@ -343,15 +336,14 @@ pub fn read_design_geometry(bytes: &[u8]) -> Option<std::sync::Arc<ModelState>>
DesignContainerReader::new(Cursor::new(bytes)).ok()?.geometry()?.final_state().ok()
}
/// Whether `manifest` is a model cache of this build's evaluation source and kernel 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 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")))
}
+7 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-kernel"
description = "SolidCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, meshes, measures and STEP, behind our own API (implemented with solidcore)"
description = "SolidCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, meshes, measures and STEP, behind our own API (implemented with SMLib)"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -8,6 +8,12 @@ license.workspace = true
[lints]
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.
smlib = ["solidcraft-smlib/native"]
[dependencies]
solidcraft-geom = { workspace = true }
solidcraft-smlib = { workspace = true }
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -2,13 +2,21 @@
//! component is its own product with a package body and its own pins (nothing shared).
//!
//! ```sh
//! scripts/fetch-smlib.sh
//! SOLIDCRAFT_STEP_PCB_COMPONENTS=600 SOLIDCRAFT_STEP_PCB_PINS=15 \
//! cargo run --release -p solidcraft-kernel --example step_board_fixture -- board.step
//! cargo run --release -p solidcraft-kernel --features smlib --example step_board_fixture -- board.step
//! ```
//!
//! 600 components of 15 pins give 52 MB. The defaults are 500 and 16; the path defaults to
//! `board.step`.
#[cfg(not(feature = "smlib"))]
fn main() {
eprintln!("step_board_fixture needs SMLib: run scripts/fetch-smlib.sh, then add --features smlib");
std::process::exit(2);
}
#[cfg(feature = "smlib")]
fn main() {
if let Err(e) = run::main() {
eprintln!("step_board_fixture: {e}");
@@ -16,6 +24,7 @@ fn main() {
}
}
#[cfg(feature = "smlib")]
mod run {
use solidcraft_geom::Vec3;
use solidcraft_kernel::{Body, ExportBody, ExportProduct, StepHeader, box_solid, cylinder, fillet, step_export_products};
-28
View File
@@ -25,34 +25,6 @@ pub(crate) fn too_large(msg: &str) -> KernelError {
KernelError::TooLarge(msg.into())
}
/// The note on a blend's refusal as too large that names the largest `size` (radius, distance)
/// that fits: "the largest radius that fits is 3 mm". The kernel's own sized refusals and a fillet
/// feature's search for the size ([`fillet_max`]) word it alike.
pub fn largest_fit_note(size: &str, largest: f64) -> String {
format!("the largest {size} that fits is {} mm", trimmed_mm(largest))
}
/// Whether a refusal already names the largest size that fits ([`largest_fit_note`]).
pub fn names_largest_fit(why: &str) -> bool {
why.contains("that fits is ")
}
/// `why` with the largest size that fits it names ([`largest_fit_note`]) replaced by `largest`:
/// solidcore names the size less a margin (34.99 where 35 builds), a feature's search the
/// largest it built. `why` itself where it names none.
pub fn with_fit_note(why: &str, size: &str, largest: f64) -> String {
let Some(start) = why.find("the largest ") else { return why.to_owned() };
let Some(len) = why.get(start..).and_then(|rest| rest.find(" mm")) else { return why.to_owned() };
let (head, tail) = (why.get(..start).unwrap_or_default(), why.get(start + len + 3..).unwrap_or_default());
format!("{head}{}{tail}", largest_fit_note(size, largest))
}
/// `v` to the micrometre, without trailing zeros.
pub(crate) fn trimmed_mm(v: f64) -> String {
let s = format!("{v:.6}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
+11 -20
View File
@@ -123,8 +123,8 @@ mod imp {
use crate::native::{body_from_smlib_ordered, smlib_body_ordered};
use crate::{KernelError, Result, guard, record};
fn failed(what: &str, step: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib {what}: {step}"), e)
fn failed(what: &str, step: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib {what}: {step}: {e}"))
}
/// How far a pick may lie from its edge.
@@ -444,24 +444,15 @@ mod imp {
"not supported yet: the {what} meets a corner of this kind, which SMLib's blends do not handle yet (where its edges \
run on tangentially into each other, or meet in a way no blend corner builds)"
)),
SmError::NoRoom => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
SmError::TooLarge { size, kind, at, why } => {
let mut beside = match at {
Some([x, y, z]) => format!(", at the edge at ({x:.3}, {y:.3}, {z:.3})"),
None => String::new(),
};
if let Some(why) = why {
beside = format!("{beside}: {}", why.words());
}
let fits = match kind {
solidcraft_smlib::FitKind::Scale => {
format!("the largest that fits is {} times the distances given", crate::blend::trimmed_mm(size))
}
kind => crate::blend::largest_fit_note(kind.word(), size),
};
KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow{beside}; {fits}"))
}
SmError::Unported(why) => KernelError::Invalid(format!("not supported yet: {why}")),
// A setback too short for its corner's fill (SMLib certifies it convex only from
// its least on) names that least; any other is a blend too large for its room.
SmError::NoRoom => match solidcraft_smlib::blend_fit() {
Some(solidcraft_smlib::Fit::SetbackAtLeast(least)) => KernelError::Invalid(format!(
"the setback is too short for its corner: {}{least:.4} mm (the corner's patch is convex only from there)",
crate::SETBACK_AT_LEAST
)),
_ => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
},
e => failed(what, "operation", e),
})?;
// Checked here unless the evaluation's validation mode leaves it to the feature's end
+4 -11
View File
@@ -420,8 +420,6 @@ pub struct Refusal {
pub surface: Option<SurfaceKind>,
/// SMLib's reason for the whole body, as the log words it.
pub why: String,
/// The reason is a panic the wrapper caught, not a refusal ([`KernelError::Internal`]).
pub internal: bool,
}
/// Where a point lies against a body ([`Body::classify_point`]).
@@ -453,16 +451,13 @@ const MAX_REFUSAL_PROBES: usize = 20_000;
impl Refusal {
/// The refusal of `record` for `error`, with the face SMLib refuses on its own.
fn of(record: &Brep, error: &solidcraft_smlib::Error) -> Refusal {
if let solidcraft_smlib::Error::Internal(why) = error {
return Refusal { face: None, surface: None, why: why.clone(), internal: true };
}
let _span = solidcraft_profile::span("kernel", "smlib_refusal_face");
let alone = |f: usize| crate::surfaces::Parts::of(record, &[(f, false)]).and_then(|p| p.canonical()).map(|c| c.brep);
let face = (record.faces.len() <= MAX_REFUSAL_PROBES)
.then(|| (0..record.faces.len()).find(|&f| alone(f).is_ok_and(|r| solidcraft_smlib::Brep::from_record(&r, true).is_err())))
.flatten();
let surface = face.and_then(|f| record.faces.get(f)).map(|f| SurfaceKind::of(&f.surface));
Refusal { face, surface, why: error.to_string(), internal: false }
Refusal { face, surface, why: error.to_string() }
}
}
@@ -545,9 +540,7 @@ impl Body {
self.smlib
.get_or_init(|| solidcraft_smlib::Brep::from_record(&self.record, true).map_err(|e| Refusal::of(&self.record, &e)))
.as_ref()
.map_err(
|r| if r.internal { KernelError::Internal(format!("SMLib: {}", r.why)) } else { KernelError::Failed(format!("SMLib: {}", r.why)) },
)
.map_err(|r| KernelError::Failed(format!("SMLib: {}", r.why)))
}
/// Why SMLib refuses the body, if it does: the body is sent into SMLib as an operation
@@ -1054,7 +1047,7 @@ impl Body {
if self.is_mesh() {
return Ok(self.outline_bounds());
}
let (lo, hi) = self.smlib()?.bounds().map_err(|e| KernelError::smlib("SMLib bounds", e))?;
let (lo, hi) = self.smlib()?.bounds().map_err(|e| KernelError::Failed(format!("SMLib bounds: {e}")))?;
Ok(Aabb3 { min: v3(lo), max: v3(hi) })
}
@@ -1387,7 +1380,7 @@ impl Body {
PointClass::Outside
});
}
let class = self.smlib()?.classify_point([p.x, p.y, p.z]).map_err(|e| KernelError::smlib("SMLib point classification", e))?;
let class = self.smlib()?.classify_point([p.x, p.y, p.z]).map_err(|e| KernelError::Failed(format!("SMLib point classification: {e}")))?;
Ok(match class {
solidcraft_smlib::PointClass::Inside => PointClass::Inside,
solidcraft_smlib::PointClass::Outside => PointClass::Outside,
@@ -429,6 +429,7 @@ fn stacked_pieces_of_one_cylinder_keep_their_walls() {
/// The same booleans through SMLib: its tags, renumbered into the operands' and the result's
/// face and edge order, give a history that holds against the geometry.
#[cfg(feature = "smlib")]
mod smlib {
use super::*;
use crate::smlib_boolean::smlib_boolean;
+1 -1
View File
@@ -919,7 +919,7 @@ pub fn fair_faces(body: &Body, faces: &[usize], opts: &FairOptions) -> Result<(B
if changed.is_empty() {
return Ok((body.clone(), report));
}
let smlib = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::smlib("fair: SMLib", e))?;
let smlib = solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("fair: SMLib: {e}")))?;
let (result, order) = crate::native::body_from_smlib_ordered(&smlib)?;
// The topology is the body's own, so SMLib's order is the body's: each face kept or
// modified, each edge kept. Where SMLib's order is not the record's the history is not known.
+2 -2
View File
@@ -795,7 +795,7 @@ fn patch_body(body: &Body, record: &b::Brep, chain: &[(usize, bool)], surface: b
}
let mut made = assemble(Parts { vertices, edges, faces: vec![PartFace { surface, forward: true, loops: vec![uses] }], tolerance: tol }, false)?;
hold_gap(&mut made.brep, tol);
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::smlib("patch: SMLib", e))?;
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::Failed(format!("patch: SMLib: {e}")))?;
let (patch, order) = crate::native::body_from_smlib_ordered(&smlib)?;
// The patch's edges in its own order, each from the loop edge it is.
let Some(order) = order else { return Ok(patch) };
@@ -920,7 +920,7 @@ fn quilt_body(body: &Body, record: &b::Brep, chain: &[Piece], fill: &IsoFill, to
}
let mut made = assemble(Parts { vertices, edges, faces, tolerance: tol }, false)?;
hold_gap(&mut made.brep, tol);
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::smlib("patch: SMLib", e))?;
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::Failed(format!("patch: SMLib: {e}")))?;
let (sheet, order) = crate::native::body_from_smlib_ordered(&smlib)?;
let Some(order) = order else { return Ok(sheet) };
let place = |list: &[usize], c: usize| if list.is_empty() { Some(c) } else { list.get(c).copied() };
+2 -2
View File
@@ -45,8 +45,8 @@ fn a_setback_off_the_rounded_corner_is_refused() {
/// A setback that does not reach past where the corner's rounds meet without one (on faces square
/// to each other, the radius) is refused before SMLib runs, naming that least setback. Past it, a
/// setback still too short for the corner fill (certified convex only from a setback on, as SMLib
/// f916a8d's setback corner does) is refused naming the smallest that builds,
/// setback still too short for SMLib's corner fill (certified convex only from a setback on,
/// smlib_blend_fit_size's SMLIB_FIT_SETBACK_AT_LEAST) is refused naming the smallest that builds,
/// at most twice the one asked for (the certificate scales the setbacks up to 2); that one builds.
#[test]
fn a_setback_not_past_the_radius_is_refused_naming_the_least() {
+2 -2
View File
@@ -22,8 +22,8 @@ pub struct MeetingEdge {
pub points: Vec<Vec3>,
}
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib intersection curve: {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib intersection curve: {what}: {e}"))
}
/// `body` itself, or its faces `faces` (in its face order) alone as a surface body.
-23
View File
@@ -361,26 +361,3 @@ fn rounds_ending_in_a_sphere_of_any_size_never_panic() {
}
}
}
/// The front's top edge rounded r 2, then the edge between that round and the arc wall (the
/// middle of the arc top edge's tangent chain) at the round's own radius: the ball sits on the
/// round's axis, fixed, and the plane square to the edge at its top end misses that centre, so
/// no round ends there. solidcore refuses it as a collapsed offset (`NoRoomReason::
/// OffsetCollapses`, solidcore#2431), naming 2 less its fit margin; the refusal says why.
#[test]
fn a_ball_as_wide_as_the_round_it_rolls_on_is_refused_saying_why() {
let rounded = fillet(&part(), &[tf()], 2.0).unwrap();
let middle = Vec3::new(41.17486688363082, -20.41321806796856, 9.415188112149071);
let why = match fillet(&rounded, &[middle], 2.0) {
Err(KernelError::TooLarge(why)) => why,
other => panic!("{:?}", other.map(|b| b.face_count())),
};
assert!(why.contains("the ball is as wide as a face it rolls on, so its centre stays put and the round cannot end at the edge's end"), "{why}");
// 2 less solidcore's fit margin.
let largest: f64 = why
.split_once("the largest radius that fits is ")
.and_then(|(_, n)| n.split_once(" mm"))
.and_then(|(n, _)| n.parse().ok())
.unwrap_or_else(|| panic!("{why}"));
assert!(largest < 2.0 && largest > 1.9, "{why}");
}
+4 -53
View File
@@ -97,7 +97,7 @@ mod ops;
#[cfg(test)]
mod outline_tests;
mod par;
pub use par::{KERNEL_STACK, for_each_on_threads, kernel_thread, map_on_threads, map_with_local_pools};
pub use par::{for_each_on_threads, map_on_threads, map_with_local_pools};
#[cfg(test)]
mod pinch_tests;
#[cfg(test)]
@@ -157,8 +157,7 @@ pub mod watch;
pub use blend::{
ChamferSide, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners,
fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_removing, fillet_setback, largest_fit_note,
names_largest_fit, tangent_chain, with_fit_note,
fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_removing, fillet_setback, tangent_chain,
};
pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
@@ -237,7 +236,7 @@ pub use topo::{CylinderFace, cylinder_end_at, cylinder_face, cylinder_face_at, m
pub use topo::{TopoCounts, merged_topology, seam_flags};
pub use tspline::tspline_sheet;
pub use validation::{Validation, check_kept, validating};
pub use validity::{SmlibCheck, Validity, smlib_dump_dir, validity};
pub use validity::{SmlibCheck, Validity, smlib_built, smlib_dump_dir, validity};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
@@ -256,48 +255,6 @@ pub enum KernelError {
pub type Result<T> = std::result::Result<T, KernelError>;
/// Why a kernel step failed: a message, or an error of the solidcore wrapper, which may be a
/// panic the wrapper caught ([`solidcraft_smlib::Error::Internal`]).
pub(crate) trait Cause: std::fmt::Display {
/// The panic's message, when the cause is one.
fn panic(&self) -> Option<&str> {
None
}
}
impl Cause for solidcraft_smlib::Error {
fn panic(&self) -> Option<&str> {
match self {
solidcraft_smlib::Error::Internal(m) => Some(m),
_ => None,
}
}
}
impl Cause for str {}
impl Cause for String {}
impl Cause for solidcraft_smlib::CheckReport {}
impl Cause for solidcraft_smlib::export::ExportError {}
impl Cause for solidcraft_geom::brep::history::Failed {}
impl Cause for solidcraft_geom::brep::pcurve::Error {}
impl<T: Cause + ?Sized> Cause for &T {
fn panic(&self) -> Option<&str> {
(**self).panic()
}
}
impl KernelError {
/// A kernel step's failure, `context` before its cause: a panic the solidcore wrapper caught
/// (its kernel replaced by a fresh one) stays an internal error ([`KernelError::Internal`]),
/// so the engine reports it as one; any other cause is the operation's failure.
pub(crate) fn smlib(context: impl std::fmt::Display, e: impl Cause) -> KernelError {
match e.panic() {
Some(m) => KernelError::Internal(format!("{context}: {m}")),
None => KernelError::Failed(format!("{context}: {e}")),
}
}
}
/// The native kernel call running right now (SMLib), for crash reports.
pub fn native_call_in_flight() -> Option<String> {
solidcraft_smlib::current_call()
@@ -310,13 +267,7 @@ thread_local! {
/// Is this thread inside [`guard`]? (A panic now will be caught and turned into an error; the
/// engine's crash reports mark it recovered.)
pub fn guarded() -> bool {
GUARDED.with(std::cell::Cell::get) > 0 || solidcraft_smlib::in_call()
}
/// A panic inside a solidcore call, caught there and reported as the kernel's internal error
/// ([`KernelError::Internal`]): the crash-report check (`diagnostics.crash_test`'s kernel mode).
pub fn crash_test() -> Result<()> {
solidcraft_smlib::crash_test().map_err(|e| KernelError::smlib("crash test", e))
GUARDED.with(std::cell::Cell::get) > 0
}
/// Run third-party kernel code, converting a panic into an error.
+1 -1
View File
@@ -102,7 +102,7 @@ pub fn measure_mass(b: &Body) -> Result<MassMeasure> {
return Ok(MassMeasure { volume: m.volume, area: m.area, centroid: m.centroid, bbox: m.bbox, measured_by: m.measured_by, notes: m.notes });
}
let m = crate::guard("SMLib mass properties", || {
b.smlib()?.mass_properties(ACCURACY).map_err(|e| crate::KernelError::smlib("SMLib mass properties", e))
b.smlib()?.mass_properties(ACCURACY).map_err(|e| crate::KernelError::Failed(format!("SMLib mass properties: {e}")))
})?;
let [x, y, z] = m.centroid;
Ok(MassMeasure { volume: m.volume, area: m.area, centroid: Vec3::new(x, y, z), bbox: b.exact_bounds()?, measured_by: "smlib", notes: Vec::new() })
@@ -523,6 +523,7 @@ fn chamfer_after_box_cuts() {
/// Shell, offset faces and draft straight through `crate::offset_backend`: refusals, and their
/// histories checked against the geometry and their numbers against the analytic ones.
#[cfg(feature = "smlib")]
mod smlib {
use super::*;
use crate::offset_backend;
+3 -3
View File
@@ -87,7 +87,7 @@ pub fn brep_record(body: &Body) -> Result<solidcraft_geom::brep::Brep> {
/// The body in SMLib (see [`Body::smlib`]), as an owned copy.
pub fn smlib_body(body: &Body) -> Result<solidcraft_smlib::Brep> {
let _span = solidcraft_profile::span("kernel", "smlib_body");
body.smlib()?.try_clone().map_err(|e| KernelError::smlib("SMLib", e))
body.smlib()?.try_clone().map_err(|e| KernelError::Failed(format!("SMLib: {e}")))
}
/// Decode bounded untrusted data. Validate indices before topology extraction.
@@ -140,7 +140,7 @@ pub(crate) fn body_from_smlib_ordered(brep: &solidcraft_smlib::Brep) -> Result<(
/// the body's record does. `None` where it does not.
pub(crate) fn smlib_body_ordered(body: &Body) -> Result<(solidcraft_smlib::Brep, Option<solidcraft_smlib::history::Order>)> {
let brep = smlib_body(body)?;
Ok((brep.try_clone().map_err(|e| KernelError::smlib("SMLib", e))?, numbering(body.record(), &brep)))
Ok((brep.try_clone().map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?, numbering(body.record(), &brep)))
}
/// `record` in SMLib, imported as given (SMLib's own form; a record it refuses is an error that
@@ -149,7 +149,7 @@ pub(crate) fn smlib_record_ordered(
record: &solidcraft_geom::brep::Brep,
) -> Result<(solidcraft_smlib::Brep, Option<solidcraft_smlib::history::Order>)> {
let _span = solidcraft_profile::span("kernel", "smlib_record_ordered");
let brep = solidcraft_smlib::Brep::from_record(record, true).map_err(|e| KernelError::smlib("SMLib", e))?;
let brep = solidcraft_smlib::Brep::from_record(record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))?;
let order = numbering(record, &brep);
Ok((brep, order))
}
+2 -7
View File
@@ -22,8 +22,8 @@ use crate::history::{Change, EdgeOrigin, FaceOrigin, History};
use crate::native::{body_from_smlib_ordered, smlib_body_ordered};
use crate::{KernelError, Result, record};
fn failed(op: &str, what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib {op}: {what}"), e)
fn failed(op: &str, what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib {op}: {what}: {e}"))
}
/// The error for SMLib's refusal of `op`, in the words of the operation; `wall` is its wall's
@@ -39,11 +39,6 @@ fn refused(op: &str, wall: Option<f64>, e: SmError) -> KernelError {
use a thinner or thicker wall, or move the feature"
))
}
// solidcore's one construction: a face curves tighter than the wall is thick.
SmError::WallFolds { largest } => {
let largest = largest.map(|w| format!("; the largest wall that works is {} mm", crate::blend::trimmed_mm(w))).unwrap_or_default();
KernelError::Invalid(format!("{op}: the wall is thicker than a face curves, so the face's offset would fold{largest}"))
}
SmError::NoMatch { point } => KernelError::Invalid(format!("{op}: no face at selection point {}", point + 1)),
SmError::Ambiguous { point } => KernelError::Invalid(format!("{op}: selection point {} is as near to two faces", point + 1)),
SmError::Unsupported => KernelError::Failed(format!("not supported yet: SMLib {op} for this geometry")),
+2 -2
View File
@@ -206,9 +206,9 @@ fn moved_from(history: Option<&History>, f: usize) -> Option<usize> {
/// `body` moved by a matrix that is not a rigid motion, in SMLib, its history from SMLib's tags.
fn transformed(body: &Body, m: &[[f64; 4]; 4]) -> Result<Body> {
let failed = |what: &str, e: solidcraft_smlib::Error| KernelError::smlib(format!("SMLib transform: {what}"), e);
let failed = |what: &str, e: &dyn std::fmt::Display| KernelError::Failed(format!("SMLib transform: {what}: {e}"));
let (handle, order) = crate::native::smlib_body_ordered(body)?;
let moved = handle.tagged(0).map_err(|e| failed("tag", e))?.transformed(m).map_err(|e| failed("operation", e))?;
let moved = handle.tagged(0).map_err(|e| failed("tag", &e))?.transformed(m).map_err(|e| failed("operation", &e))?;
let (result, result_order) = crate::native::body_from_smlib_ordered(&moved)?;
let history = match (order, result_order) {
(Some(o), Some(r)) => moved.history().ok().and_then(|h| h.history(&[o], &r).ok()),
+4 -57
View File
@@ -4,26 +4,6 @@
use crate::{Result, guard};
/// The stack of a thread that runs kernel work: solidcore's algorithms recurse (subdivision,
/// intersection, filleting), and its README asks for 8 MiB, a native main thread's stack, where
/// Rust starts other threads with 2 MiB.
pub const KERNEL_STACK: usize = 8 << 20;
/// A thread named `name` with the kernel's stack ([`KERNEL_STACK`]), for a thread that runs
/// kernel work (an operation, an evaluation, a mesh).
pub fn kernel_thread(name: &str) -> std::thread::Builder {
std::thread::Builder::new().name(name.to_owned()).stack_size(KERNEL_STACK)
}
/// The kernel's worker pool, its threads with the kernel's stack; `None` when it cannot start
/// (the work then runs on the calling thread).
#[cfg(not(target_arch = "wasm32"))]
fn pool() -> Option<&'static rayon::ThreadPool> {
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
POOL.get_or_init(|| rayon::ThreadPoolBuilder::new().thread_name(|i| format!("solidcraft-kernel-{i}")).stack_size(KERNEL_STACK).build().ok())
.as_ref()
}
/// `f` over `items`, results in item order: on the pool when `parallel`, else on the calling
/// thread. Waking the pool's workers costs tens of microseconds, so callers ask for it only
/// when the items carry more work than that (decided from the work, not the item count).
@@ -37,18 +17,8 @@ pub(crate) fn map<T: Sync, R: Send>(what: &'static str, items: &[T], parallel: b
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
if parallel && items.len() > 1 {
if rayon::current_thread_index().is_some() {
// On a pool's worker (the kernel's, or a one-thread pool of
// `map_with_local_pools`): that pool's threads, as many as it has.
if rayon::current_num_threads() > 1 {
return items.par_iter().map(one).collect();
}
} else if let Some(pool) = pool()
&& pool.current_num_threads() > 1
{
return pool.install(|| items.par_iter().map(one).collect());
}
if parallel && items.len() > 1 && rayon::current_num_threads() > 1 {
return items.par_iter().map(one).collect();
}
}
items.iter().map(one).collect()
@@ -80,7 +50,6 @@ pub fn map_with_local_pools<T: Sync, R: Send>(items: &[T], max_threads: usize, f
if cell.get().is_none() {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(1)
.stack_size(KERNEL_STACK)
.build()
.map_err(|e| crate::KernelError::Failed(format!("cannot start mesh worker: {e}")))?;
let _ = cell.set(pool);
@@ -122,7 +91,8 @@ pub fn map_on_threads<T: Sync, R: Send>(items: &[T], max_threads: usize, f: impl
let mut out: Vec<Option<R>> = std::iter::repeat_with(|| None).take(items.len()).collect();
std::thread::scope(|scope| {
// A thread that cannot start leaves its share to the others.
let helpers: Vec<_> = (1..threads).filter_map(|_| kernel_thread("solidcraft-each").spawn_scoped(scope, work).ok()).collect();
let helpers: Vec<_> =
(1..threads).filter_map(|_| std::thread::Builder::new().name("solidcraft-each".into()).spawn_scoped(scope, work).ok()).collect();
let mut all = work();
for h in helpers {
// `work` never panics (every item runs under the guard).
@@ -185,29 +155,6 @@ mod tests {
}
}
/// `depth` frames of recursion, each holding 64 KiB on the stack, summing their depths: 80 is
/// 5 MiB, past the 2 MiB Rust gives a thread and within the kernel's 8 MiB.
fn deep(depth: u8) -> u64 {
let frame = std::hint::black_box([depth; 64 * 1024]);
let here = u64::from(frame[usize::from(depth)]);
if depth == 0 { here } else { here + deep(depth - 1) }
}
/// Kernel work recurses as deep as solidcore's algorithms do on every thread the kernel runs
/// it on: a kernel thread, the pool's workers, and the helpers of `map_on_threads`.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn kernel_threads_have_the_kernels_stack() {
let want: u64 = (0..=80).sum();
let on_thread = kernel_thread("deep").spawn(|| deep(80)).unwrap().join().unwrap();
assert_eq!(on_thread, want);
let items = [80u8; 16];
let on_pool = kernel_thread("deep-pool").spawn(move || map("deep", &items, true, |&d| Ok(deep(d)))).unwrap().join().unwrap();
assert!(on_pool.iter().all(|r| r.as_ref().ok() == Some(&want)), "{on_pool:?}");
let on_helpers = kernel_thread("deep-helpers").spawn(move || map_on_threads(&items, 8, |&d| deep(d))).unwrap().join().unwrap();
assert!(on_helpers.iter().all(|r| *r == Some(want)), "{on_helpers:?}");
}
#[test]
fn keeps_order_and_errors() {
let items: Vec<u32> = (0..1000).collect();
+2 -2
View File
@@ -21,8 +21,8 @@ fn edge_middle(e: &brep::Edge) -> Option<Vec3> {
CurveEval::new(e.parameters()).ok()?.point(0.5 * (t0 + t1))
}
fn failed(e: impl crate::Cause) -> KernelError {
KernelError::smlib("SMLib planar regularization", e)
fn failed(e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib planar regularization: {e}"))
}
impl Body {
+27 -2
View File
@@ -108,6 +108,7 @@ struct Regions {
touching: bool,
}
#[cfg(feature = "smlib")]
mod sheets {
use solidcraft_geom::Loop2;
use solidcraft_geom::Vec3;
@@ -118,8 +119,8 @@ mod sheets {
use super::*;
use crate::prism_curve::{Run, projected};
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib planar regions: {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib planar regions: {what}: {e}"))
}
/// `regions` (in the plane's own coordinates) as one sheet on z = 0. SMLib's face builder
@@ -288,6 +289,30 @@ mod sheets {
}
}
#[cfg(not(feature = "smlib"))]
mod sheets {
use super::*;
fn refused<T>() -> Result<T> {
Err(KernelError::Failed("not supported yet: planar region booleans run on SMLib (build with the feature `smlib`)".into()))
}
/// A sheet stands for nothing without SMLib.
pub(super) enum Sheet {}
pub(super) fn sheet(_: &[Region2], _: f64) -> Result<Sheet> {
refused()
}
pub(super) fn combine(a: &Sheet, _: &Sheet, _: BoolOp) -> Result<Option<Sheet>> {
match *a {}
}
pub(super) fn regions(sheet: &Sheet, _: f64) -> Result<Regions> {
match *sheet {}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod planar_tests {
+2 -1
View File
@@ -302,7 +302,8 @@ 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.
/// in a plane parallel to `plane` or isn't read exactly. Only planar sheets read on SMLib use it.
#[cfg(feature = "smlib")]
pub(crate) fn projected(run: &Run, plane: &Plane, tol: f64, work: &mut usize) -> Option<Vec<Seg2>> {
read(run, plane, tol, work, true)
}
+3 -4
View File
@@ -192,10 +192,9 @@ 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() {
// The geometry kernel's calls are children of the boolean: their time is not its self
// time.
let calls: std::time::Duration = rep.rows.iter().filter(|r| r.category == "solidcore").map(|r| r.total).sum();
assert!(calls > std::time::Duration::ZERO && k.self_time < k.total, "{rep:?}");
// 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:?}");
}
}
}
+1 -1
View File
@@ -1221,7 +1221,7 @@ fn sew_impl(faces: &[FaceSpec], touching: bool, sources: Option<&[Option<crate::
}
}
let made = assemble(Parts { vertices, edges, faces: out, tolerance: tol }, true)?;
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::smlib("sew: SMLib", e))?;
let smlib = solidcraft_smlib::Brep::from_record(&made.brep, true).map_err(|e| KernelError::Failed(format!("sew: SMLib: {e}")))?;
let (body, order) = crate::native::body_from_smlib_ordered(&smlib)?;
// The face of `faces` each face of the body was built from: record face `c` is SMLib's
// face `c`, which the order places in the body.
+2 -2
View File
@@ -16,8 +16,8 @@ mod smlib {
use crate::validation::{Checked, Step};
use crate::{KernelError, Result, record};
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib boolean: {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib boolean: {what}: {e}"))
}
/// `a` and `b` combined by `op` in SMLib, with the history SMLib's tags give (none where a
+2 -2
View File
@@ -93,8 +93,8 @@ mod imp {
use crate::provenance::{closest_on_tri, face_tris};
use crate::record;
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib {what}: {e}"))
}
fn corner(c: crate::DraftCorner) -> DraftCorner {
+1
View File
@@ -1,5 +1,6 @@
//! The sweeps through SMLib ([`crate::smlib_sweeps`]): analytic volumes, and the history and
//! construction sources the builders' vocabulary gives each face.
#![cfg(feature = "smlib")]
use std::f64::consts::{PI, TAU};
+2 -2
View File
@@ -49,8 +49,8 @@ fn invalid(what: impl Into<String>) -> KernelError {
KernelError::Invalid(what.into())
}
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib {what}: {e}"))
}
fn a3(v: Vec3) -> [f64; 3] {
+13 -1
View File
@@ -540,6 +540,17 @@ 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.
#[cfg(not(feature = "smlib"))]
#[test]
fn a_solid_smlib_refuses_with_its_faces_is_refused_not_meshed() {
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}");
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
@@ -2002,10 +2013,11 @@ pub(super) fn schema_4_volume(text: &str) -> f64 {
measure(&Body::from_record(r.solids[0].brep.clone()).unwrap()).unwrap().volume
}
/// The step-conformance parts read into the kernel: the STEP reader's
/// The step-conformance parts read into SMLib (`SOLIDCRAFT_KERNEL=smlib`): the STEP reader's
/// schema 4 record, SMLib's body of it, back as a kernel body. Each has its exact volume and
/// face count, and every face is traced to a face of the file (the NURBS cylinder, dome and
/// torus ring among them: their faces wind around a spline closed on itself, and get a seam).
#[cfg(feature = "smlib")]
#[test]
fn step_conformance_parts_read_into_smlib() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/step-conformance");
+2 -2
View File
@@ -387,8 +387,8 @@ fn back_to_bounds(body: &Body, faces: &[usize]) -> Result<Body> {
Ok(back)
}
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib extend: {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib extend: {what}: {e}"))
}
#[cfg(test)]
+1 -1
View File
@@ -102,7 +102,7 @@ pub fn trim_keep(body: &Body, tool: &SplitTool, keep: TrimKeep) -> Result<Body>
fn face_sides(body: &Body, side: &ToolSide) -> Result<Vec<f64>> {
let (sm, order) = crate::native::smlib_body_ordered(body)?;
let order = order.ok_or_else(|| KernelError::Failed("trim: SMLib numbers the pieces' faces differently from their record".into()))?;
let points = sm.face_points().map_err(|e| KernelError::smlib("SMLib trim: a point inside each piece", e))?;
let points = sm.face_points().map_err(|e| KernelError::Failed(format!("SMLib trim: a point inside each piece: {e}")))?;
let mut sides = vec![f64::NAN; points.len()];
for (i, p) in points.iter().enumerate() {
let face = if order.faces.is_empty() { i } else { order.faces.get(i).copied().unwrap_or(usize::MAX) };
+2 -2
View File
@@ -102,8 +102,8 @@ pub fn untrim(body: &Body, face: usize, holes: UntrimHoles, outer: bool, in_plac
Ok(out.with_checked_history(history, &crate::record::counts(&[body])))
}
fn failed(what: &str, e: impl crate::Cause) -> KernelError {
KernelError::smlib(format!("SMLib untrim: {what}"), e)
fn failed(what: &str, e: impl std::fmt::Display) -> KernelError {
KernelError::Failed(format!("SMLib untrim: {what}: {e}"))
}
/// A point inside face `face` of `body`: the centre of its largest mesh triangle.
+1 -1
View File
@@ -1236,7 +1236,7 @@ mod imp {
use super::*;
fn sm(what: &'static str) -> impl Fn(solidcraft_smlib::Error) -> KernelError {
move |e| KernelError::smlib(format!("SMLib {what}"), e)
move |e| failed(format!("SMLib {what}: {e}"))
}
pub(super) fn refuse(what: &str) -> KernelError {
+2 -17
View File
@@ -1,14 +1,9 @@
use super::*;
/// A wedge 40 x 20, 15 high, its top front edge along x from 0 to 40.
fn wedge_body() -> Body {
fn wedge() -> Body {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let outer = Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(40., 0.), Vec2::new(0., 20.)]);
crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap()
}
fn wedge() -> Body {
let body = wedge_body();
let body = crate::extrude(&Plane::XY, &[Region2 { outer, holes: vec![] }], 0., 15.).unwrap().pop().unwrap();
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
@@ -17,16 +12,6 @@ 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
+10
View File
@@ -373,6 +373,7 @@ fn closed_display_mesh(name: &str, b: &Body, tol: f64) -> Mesh {
/// A sphere from SMLib cut through its centre: the half sphere is bounded by its rim and its
/// seam from the pole up to the rim, used both ways. The seam's two uses share their nodes, so
/// the mesh has no crack along it and nothing degenerate at the pole.
#[cfg(feature = "smlib")]
#[test]
fn a_half_sphere_from_smlib_is_closed_along_its_seam() {
use solidcraft_smlib::{BooleanOp, Brep as S};
@@ -405,6 +406,7 @@ fn display_plans_stay_within_the_chord_tolerance() {
}
/// SMLib's torus is one face bounded by its two seams: a grid over the whole parameter box.
#[cfg(feature = "smlib")]
#[test]
fn an_smlib_torus_stays_within_the_chord_tolerance() {
let Ok(ring) = solidcraft_smlib::Brep::make_torus([0.0; 3], 22.0, 4.0) else { panic!("torus") };
@@ -422,6 +424,7 @@ fn an_smlib_torus_stays_within_the_chord_tolerance() {
/// A groove of trapezoid `section` (radius, axial offset) in the plane through the z axis along
/// y, carried `turns` turns of `pitch` about z from `base`, as an SMLib solid.
#[cfg(feature = "smlib")]
fn smlib_groove(section: [(f64, f64); 4], base: f64, pitch: f64, turns: f64) -> solidcraft_smlib::Brep {
use solidcraft_geom::{Loop2, Vec2};
smlib_screw(Loop2::polygon(&section.map(|(r, z)| Vec2::new(r, z))), base, pitch, turns)
@@ -429,6 +432,7 @@ fn smlib_groove(section: [(f64, f64); 4], base: f64, pitch: f64, turns: f64) ->
/// The `section` (radius, axial offset) in the plane through the z axis along y, carried
/// `turns` turns of `pitch` about z from `base`, as an SMLib solid.
#[cfg(feature = "smlib")]
fn smlib_screw(section: solidcraft_geom::Loop2, base: f64, pitch: f64, turns: f64) -> solidcraft_smlib::Brep {
use solidcraft_geom::{Plane, Region2};
let origin = Vec3::new(0.0, 0.0, base);
@@ -440,6 +444,7 @@ fn smlib_screw(section: solidcraft_geom::Loop2, base: f64, pitch: f64, turns: f6
ok(crate::native::smlib_body(&groove))
}
#[cfg(feature = "smlib")]
fn smlib_cut(a: &solidcraft_smlib::Brep, b: &solidcraft_smlib::Brep) -> solidcraft_smlib::Brep {
match a.boolean(b, solidcraft_smlib::BooleanOp::Subtract) {
Ok(Some(r)) => r,
@@ -450,6 +455,7 @@ fn smlib_cut(a: &solidcraft_smlib::Brep, b: &solidcraft_smlib::Brep) -> solidcra
/// The ISO basic profile's groove for pitch `p` and major diameter `d`: internal, from 0.1 p
/// inside the hole wall of radius `wall` out to the major radius; external, from the root out
/// past the shaft's surface (radius `wall`).
#[cfg(feature = "smlib")]
fn iso_groove(p: f64, d: f64, wall: f64, internal: bool) -> [(f64, f64); 4] {
let h = p * 3f64.sqrt() / 2.0;
let slope = (PI / 6.0).tan();
@@ -472,6 +478,7 @@ fn iso_groove(p: f64, d: f64, wall: f64, internal: bool) -> [(f64, f64); 4] {
/// parabolic segment) that flat triangles bridge rather than follow: a face no wider than its
/// chords sag (the thread's crest band where it runs out through the block's end, 0.06 mm sag
/// at 0.1) takes up to that much more or less area however it is triangulated inside.
#[cfg(feature = "smlib")]
fn assert_faces_lie_flat(name: &str, plan: &solidcraft_geom::tess::TessPlan, tol: f64) {
for fp in &plan.faces {
let Some(s) = fp.surface.and_then(|i| plan.surfaces.get(i as usize)) else { continue };
@@ -523,6 +530,7 @@ fn assert_faces_lie_flat(name: &str, plan: &solidcraft_geom::tess::TessPlan, tol
/// #1995: an M8x1 thread through a 7 mm hole in a 20 mm block, both ends open. Truck meshed the
/// groove's winding walls with triangles reaching past both ends of the block; the body's mesh
/// now comes from the display plan, inside the block and with the analytic volume.
#[cfg(feature = "smlib")]
#[test]
fn a_modelled_thread_in_a_bore_meshes_inside_its_block() {
use solidcraft_smlib::Brep as S;
@@ -554,6 +562,7 @@ fn a_modelled_thread_in_a_bore_meshes_inside_its_block() {
/// a triangle's side, with no four corners to blend. The butterfly of a round face made it a
/// grid of ~200 000 points whose triangles zigzagged along the strip (six times the band's area,
/// and off the surface by more than the tolerance); it is its boundary nodes alone now.
#[cfg(feature = "smlib")]
#[test]
fn a_thread_root_band_running_out_through_an_end_face_lies_on_its_surface() {
let Ok(shaft) = solidcraft_smlib::Brep::make_cylinder([0.0; 3], 5.0, 30.0) else { panic!("shaft") };
@@ -578,6 +587,7 @@ fn a_thread_root_band_running_out_through_an_end_face_lies_on_its_surface() {
/// halves go to the display plan for their mesh; at the measuring tolerance a half's wall wants
/// more cells than a grid may have, and its cut grid gave up for an under-refined Delaunay (the
/// halves measured 1.5% short of the whole). The cut grid now thins its lines to fit.
#[cfg(feature = "smlib")]
#[test]
fn the_halves_of_a_split_smlib_coil_measure_as_the_whole() {
use solidcraft_geom::{Loop2, Vec2};
+8 -42
View File
@@ -1724,14 +1724,17 @@ fn tabbed_plate_area(da: f64, dt: f64) -> f64 {
0.5 * (big * big * (PI + 2.0 * at) + tab) + (4.0 - dt) * (cy - yc)
}
/// 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 {
#[test]
fn fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole() {
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;
Loop2 {
let hole_y = 262.0 / 9.0;
let outline = 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) },
@@ -1740,44 +1743,7 @@ fn tabbed_plate_outline() -> Loop2 {
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 closed form,
/// 7.958672911836132 (its spandrel (1 − π/4) r² revolved about the disc's axis outside |x| < 4,
/// solidcore#2176's test; the rim's End, `fillet_blend_corner`, builds it to 5e-8), and the
/// corner arc's Pappus volume with flat ends, 0.15913583972191622 (the spandrel about the
/// corner's axis at 2 − its centroid over a quarter turn), 8.117808752.
#[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_672_911_836_132 + 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;
+1
View File
@@ -226,6 +226,7 @@ mod tests {
/// A box with one corner moved off its three edges: SMLib's check refuses it. Under each_step
/// the boolean that takes it refuses its result there; under each_feature it is not checked,
/// says the check was left for later, and the check of the body kept refuses it.
#[cfg(feature = "smlib")]
#[test]
fn a_body_the_check_refuses_is_refused_at_the_step_or_at_the_end() {
use solidcraft_geom::Vec3;
+18 -5
View File
@@ -54,9 +54,22 @@ fn smlib_validity(body: &Body) -> Option<SmlibCheck> {
})
}
/// Where every kernel call that fails from now on leaves its inputs for replay
/// (`solidcraft_smlib::set_dump_dir`: the call and its B-reps, which `Brep::read` reads back);
/// `None` stops it.
pub fn smlib_dump_dir(dir: Option<&std::path::Path>) {
solidcraft_smlib::set_dump_dir(dir);
/// Whether this build links SMLib (the `smlib` feature).
pub fn smlib_built() -> bool {
cfg!(feature = "smlib")
}
/// 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.
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(())
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "solidcraft-profile"
description = "SolidCraft profiler: nested, per-thread timed spans of kernel calls, kernel operations, feature evaluation, commands and imports, as a table and a Chrome trace"
description = "SolidCraft profiler: nested, per-thread timed spans of SMLib calls, kernel operations, feature evaluation, commands and imports, as a table and a Chrome trace"
version.workspace = true
edition.workspace = true
license.workspace = true
+1 -1
View File
@@ -36,7 +36,7 @@ pub const OTHER: &str = "other";
/// One span name's aggregate.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Row {
/// What the span is: `solidcore`, `kernel`, `feature`, `command`, `import`, …
/// What the span is: `smlib`, `kernel`, `feature`, `command`, `import`, …
pub category: &'static str,
pub name: &'static str,
/// Times the span ran.
+3 -3
View File
@@ -41,8 +41,8 @@ fn nested_spans_have_self_and_total_time() {
let _inner = span("test", "inner");
spin(4);
}
// Timed by someone else: a child of `outer`.
record("external", "call", Duration::from_millis(2));
// Timed by someone else (SMLib's C API): a child of `outer`.
record("smlib", "call", Duration::from_millis(2));
}
set_enabled(false);
let r = report();
@@ -57,7 +57,7 @@ fn nested_spans_have_self_and_total_time() {
let cats = r.by_category();
assert_eq!(cats.iter().map(|c| c.0).collect::<Vec<_>>().len(), 2);
let t = r.table(10);
assert!(t.contains("test outer") && t.contains("external call") && t.contains("self time by category"), "{t}");
assert!(t.contains("test outer") && t.contains("smlib call") && t.contains("self time by category"), "{t}");
}
#[test]
+25 -4
View File
@@ -1,19 +1,40 @@
[package]
name = "solidcraft-smlib"
description = "SolidCraft: the B-rep kernel, solidcore (tangency-labs/solidcore, the Rust port of NVIDIA's SMLib, Apache-2.0), behind a safe wrapper"
description = "SolidCraft: NVIDIA's SMLib B-rep kernel (tangency-labs/smlib, Apache-2.0), statically linked behind its C API, with a safe Rust wrapper"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
links = "smlib"
[lints]
workspace = true
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/ffi.rs can allow it.
# Calling a C library has no safe API. Keep the rest in sync with [workspace.lints].
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
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.
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).
web-time = { workspace = true }
[build-dependencies]
cc = { version = "1.2", features = ["parallel"] }
[dev-dependencies]
solidcraft-profile = { workspace = true, features = ["enabled"] }

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