Compare commits

..
1244 changed files with 17933 additions and 304780 deletions

No files matched your search

-38
View File
@@ -1,38 +0,0 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`.
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
default-filter = """
not (
(package(solidcraft-engine) & (
test(=tests::hostile_params_never_panic)
| test(/^tests::robustness::/)
| test(/^tests::robustness_goldens::/)
| test(=pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen)
| test(=pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs)
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
)
"""
fail-fast = false
# Every test's time in the log, so the slow ones are easy to find.
status-level = "pass"
final-status-level = "slow"
failure-output = "immediate-final"
slow-timeout = { period = "60s" }
[profile.ci.junit]
path = "junit.xml"
# The action matrix sample runs its designs in parallel worker processes, one per CPU. Run with
# other tests beside it (the heavy ones on main), a pi-case hole that takes 8 s alone (#952 makes
# it a real hole) went past the matrix's 90 s action limit. It runs on its own.
[[profile.ci.overrides]]
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
-4
View File
@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: [bherbruck]
ko_fi: bherbruck
-23
View File
@@ -1,23 +0,0 @@
---
name: Bug report
about: Help reproduce and fix a SolidCraft problem
title: ''
labels: ''
assignees: ''
---
## Behavior
What did you expect, and what happened instead?
## Reproduction
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
When the problem shows on a particular design, `solidcraft-cli script design.solidcraft --out repro.json` turns its history into a script that `solidcraft-cli eval repro.json` replays; attach it, trimmed to the failing step if you can.
## Screenshots of the behavior (when observable)
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
-17
View File
@@ -1,17 +0,0 @@
## Change
Describe the problem and resulting behavior. Link the issue when applicable.
## Validation
What did you run, and what were the results? Include expected and measured values for geometry changes.
## Screenshots of the behavior (when observable)
For observable CAD, UI or model behavior, include actual descriptive screenshots or a short recording from a small reproduction. This includes kernel, geometry, assembly and backend effects that can be shown in the app. Coding agents should run the app and capture the evidence themselves, rather than asking a human for evidence they can produce. Show the actual failure/result, preferably before and after, with brief steps, expected/actual behavior and annotations where helpful.
Flowcharts and Mermaid diagrams cannot substitute for evidence of visible behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate the point. Changes with no useful observable behavior may omit screenshot/video evidence. Exclude private designs and sensitive details; attach media or link to an accessible recording rather than committing generated review artifacts.
## Integration status
Record the upstream commit and PR status checked before handoff, any conflicts resolved, and validation after integration. Keep up with upstream merges and monitor this PR while the task is active; rebase before pushing and never force-push.
+17 -52
View File
@@ -1,60 +1,25 @@
SolidCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solidcraft/)
· [try it in the browser](https://bherbruck.github.io/solidcraft/app/) ·
[contributing](https://github.com/bherbruck/solidcraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solidcraft/issues). AI agents can model with it over MCP:
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
**What's new** in SolidCraft {version} is listed below, and the
[ROADMAP](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations) lists
the known limitations. Designs from every earlier version open in this one.
**Upgrading from 0.7 or earlier.** 0.8 changed the Linux app ID to `ai.storyteller.solidcraft`:
`flatpak update` offers to replace the old app, and the `.deb`, `.rpm` and AppImage remove the old
menu entry.
**Upgrading from 0.5 or earlier.** 0.6 renamed SolveCraft to SolidCraft and made the zip
container the default save format. `.solvecraft` designs, old settings folders and
`SOLVECRAFT_*` variables are still picked up, and `solvecraft-cli` still forwards to
`solidcraft-cli`. Want plain JSON? Use **File ▸ Save As JSON…** or `--save design.json`. On
macOS, replace `SolveCraft.app` with `SolidCraft.app`. Details:
[compatibility notes](https://github.com/bherbruck/solidcraft/blob/main/docs/compatibility.md).
Documentation, the browser app and the Flatpak feed use the SolidCraft project Pages URLs.
SolveCraft is open-source parametric 3D CAD in Rust: sketch, constrain, extrude, and change a
parameter to watch the timeline rebuild. [Documentation](https://bherbruck.github.io/solvecraft/)
· [try it in the browser](https://bherbruck.github.io/solvecraft/app/) ·
[contributing](https://github.com/bherbruck/solvecraft/blob/main/CONTRIBUTING.md) ·
[issues](https://github.com/bherbruck/solvecraft/issues). AI agents can model with it over MCP:
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solvecraft/guide/mcp.html)).
**Which file?**
- **Windows:** `SolidCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
`-portable.zip` (x64, x86 and arm64).
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag `SolidCraft.app` onto Applications.
The CLI is in `solidcraft-cli-{version}-macos-universal.zip`.
- **macOS 11+** (Apple silicon and Intel): open the `.dmg` and drag SolveCraft onto Applications.
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
- **Linux** (x86_64 and aarch64, glibc 2.35+): the `.AppImage` (`chmod +x`, then run it), the
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), the `.snap`
(`sudo snap install --dangerous <file>`, or `sudo snap install solidcraft` from the Snap Store), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solidcraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solidcraft-web-{version}.zip` is a static site to host yourself.
`.deb` or `.rpm`, the `.flatpak` (`flatpak install --user <file>`), or the `.tar.gz`.
- **FreeBSD:** `tar -xzf solvecraft-{version}-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local`.
- **Web:** `solvecraft-web-{version}.zip` is a static site to host yourself.
- `SHA256SUMS.txt`: `sha256sum -c SHA256SUMS.txt --ignore-missing`.
- Files without a version in the name (`solidcraft-windows-x64.msi`, `solidcraft-linux-x86_64.AppImage`, …)
are the same packages under fixed names, for download links that always fetch the latest release.
For Flatpak feed installation and updates, add Flathub for the required runtime before
installing SolidCraft; keep both remotes in the same user installation:
```sh
flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
flatpak install --user solidcraft ai.storyteller.solidcraft
flatpak run ai.storyteller.solidcraft
```
Flatpak installs from 0.7 or earlier use the old ID `ai.storyteller.solvecraft`: `flatpak update --user`
offers to replace it with `ai.storyteller.solidcraft`, and settings move along.
Single-file `.flatpak` bundles include the runtime repository address; missing runtimes
still need an internet connection. [Linux installation details](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
The builds aren't signed yet. On macOS, right-click `SolidCraft.app` in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolidCraft.app`).
The builds aren't signed yet. On macOS, right-click SolveCraft in Applications and choose
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`.
Licences are built in (Help ▸ About ▸ Licences, `solvecraft-cli licences`); the packages also
install `THIRD-PARTY-LICENSES.txt`. Known limitations:
[ROADMAP.md](https://github.com/bherbruck/solvecraft/blob/main/ROADMAP.md#known-limitations).
+13 -273
View File
@@ -1,15 +1,9 @@
# Fast checks always run; native builds run for code/CI changes.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
name: CI
on:
pull_request:
push:
branches: [main]
schedule:
- cron: "17 6 * * *"
workflow_dispatch:
inputs:
cross_compile:
@@ -21,34 +15,18 @@ permissions:
contents: read
concurrency:
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
group: ci-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
dependency-advisories:
name: Dependency advisories
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- name: Install advisory scanner
run: cargo install cargo-deny --version 0.20.2 --locked
- name: Check locked dependencies
run: cargo deny --locked check --warn unmaintained advisories bans sources
changes:
name: Select checks
runs-on: ubuntu-24.04
outputs:
native: ${{ steps.paths.outputs.native }}
kernel: ${{ steps.paths.outputs.kernel }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -61,14 +39,12 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
native=false
kernel=false
if [ "$EVENT_NAME" != pull_request ]; then
if [ "$EVENT_NAME" = workflow_dispatch ]; then
native=true
else
# Compare both old and new paths of renames; unknown paths run native checks.
git diff --no-renames --name-only -z "$BASE_SHA...$HEAD_SHA" > "$RUNNER_TEMP/ci-paths"
while IFS= read -r -d '' path; do
case "$path" in crates/kernel/*|vendor/truck-*) kernel=true ;; esac
case "$path" in
.github/workflows/ci.yml) native=true ;;
*.rs|Cargo.toml|Cargo.lock|*/Cargo.toml|.cargo/*|rust-toolchain*) native=true ;;
@@ -78,7 +54,6 @@ jobs:
done < "$RUNNER_TEMP/ci-paths"
fi
echo "native=$native" >> "$GITHUB_OUTPUT"
echo "kernel=$kernel" >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
@@ -100,8 +75,6 @@ jobs:
run: cargo xtask licences --check
- name: Dependency layers
run: cargo xtask layers
- name: Web app offline use and updates (headless Chrome)
run: cargo xtask web-offline-test
clippy:
name: Native Linux Clippy
@@ -119,12 +92,12 @@ jobs:
key: native-clippy
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
build-tests:
name: Build tests
tests:
name: Native Linux tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
timeout-minutes: 90
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
@@ -140,241 +113,18 @@ jobs:
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- name: Compile tests into a nextest archive
run: cargo nextest archive --workspace --cargo-profile ci --locked --archive-file tests.tar.zst
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: nextest-archive
path: tests.tar.zst
retention-days: 1
compression-level: 0
- name: Compile tests
run: cargo test --workspace --profile ci --locked --no-run
- name: Run tests
# Every test binary at once (cargo test runs them one after another).
run: cargo xtask test --locked
- name: Compiler cache statistics
if: always()
run: sccache --show-stats
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, build-tests]
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
SHARD: ${{ matrix.shard }}
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
if [ "$EVENT_NAME" != pull_request ]; then all=(--ignore-default-filter); fi
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/4" "${all[@]}"
- name: Robustness goldens sample (pull requests that touch the kernel)
# Every 8th seed of tests/robustness/goldens.json, a quarter per shard; pushes to main and
# the nightly run every seed in the step above.
if: github.event_name == 'pull_request' && needs.changes.outputs.kernel == 'true'
env:
SHARD: ${{ matrix.shard }}
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter \
-E "package(solidcraft-engine) & test(=tests::robustness_goldens::sample_$((SHARD - 1)))"
- name: Upload test timings
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
path: target/nextest/ci/junit.xml
retention-days: 7
action-matrix:
# Every ordinary action on every face, edge, vertex and body of the design corpus, against
# tests/action_matrix/baseline.json (docs/action-matrix.md). Every test run checks a sample;
# the whole matrix runs nightly (or by hand) in shards of about equal cost, cut by
# tests/action_matrix/costs.json. Keep SHARDS in step with the matrix below.
name: Action matrix (${{ matrix.shard }}/16)
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 55
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run one shard of the matrix
# A step limit below the job's, so the outcomes so far are still uploaded.
timeout-minutes: 45
env:
SHARDS: 16
SHARD: ${{ matrix.shard }}
OUT: ${{ github.workspace }}/action-matrix
run: |
mkdir -p "$OUT"
export ACTION_MATRIX_SHARD="$SHARD/$SHARDS" ACTION_MATRIX_RESULTS="$OUT/results-$SHARD.json" \
ACTION_MATRIX_TIMINGS="$OUT/timings-$SHARD.jsonl" ACTION_MATRIX_WORKDIR="$OUT/raw-$SHARD"
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_shard)"
- name: Upload the outcomes and timings
# `cargo xtask action-matrix --from DIR --bless` (with every shard's files in DIR)
# records them in the baseline and costs.json.
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: action-matrix-${{ matrix.shard }}
path: action-matrix
retention-days: 14
action-matrix-determinism:
# A sample of the matrix run twice on one runner: every action must come out the same.
name: Action matrix determinism
needs: build-tests
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run a sample twice
run: |
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --success-output final --run-ignored only -E "binary(action_matrix) & test(=action_matrix_determinism)"
doc-tests:
name: Doc tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: doc-tests
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
# nextest does not run doc tests.
- run: cargo test --workspace --profile ci --locked --doc
oracle:
name: Reference tests
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_INCREMENTAL: "0"
RUSTC_WRAPPER: sccache
SCCACHE_GHA_ENABLED: "true"
SCCACHE_IGNORE_SERVER_IO_ERROR: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: oracle
- name: Set up compiler cache
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.16.0
- name: Build the command line
run: cargo build -p solidcraft-cli --profile ci --locked
- name: Replay tests/oracle and compare with expected.json
run: cargo xtask oracle --cli target/ci/solidcraft-cli
- name: Upload the report
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: oracle-report
path: docs/oracle.md
# D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than
# DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the
# GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its
# results with the CPU.
windows-fxc:
name: Windows D3D12 FXC shaders
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: windows-latest
timeout-minutes: 45
env:
WGPU_BACKEND: dx12
WGPU_DX12_COMPILER: fxc
SOLIDCRAFT_REQUIRE_GPU: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-fxc
# 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
# in SolidCraft or FXC; serial runs never hit it.
- run: cargo test -p solidcraft-gpu --profile ci --locked -- --test-threads=1
# The Windows app starts D3D12 first and only the backend it uses (a Vulkan driver crashed the
# app at startup on Intel). Start the real app on the runner (its D3D12 adapter is WARP) with
# several saved settings and safe mode, and check each draws a frame and quits cleanly.
windows-startup:
name: Windows app startup
needs: changes
if: needs.changes.outputs.native == 'true'
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: windows-startup
- name: Backend order and probing on Windows
run: cargo test -p solidcraft --profile ci --locked -- startup::tests native_crash::tests
- name: Build the app
run: cargo build -p solidcraft --profile ci --locked
- name: Start the app under different settings
shell: pwsh
run: tools/windows-startup-smoke.ps1 -Exe target/ci/solidcraft.exe
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
needs: [changes, checks, clippy, tests]
if: always()
runs-on: ubuntu-24.04
steps:
@@ -384,23 +134,13 @@ jobs:
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
CLIPPY: ${{ needs.clippy.result }}
BUILD: ${{ needs.build-tests.result }}
TESTS: ${{ needs.tests.result }}
DOCS: ${{ needs.doc-tests.result }}
ORACLE: ${{ needs.oracle.result }}
WINDOWS_FXC: ${{ needs.windows-fxc.result }}
WINDOWS_STARTUP: ${{ needs.windows-startup.result }}
run: |
test "$CHANGES" = success
test "$CHECKS" = success
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
test "$TESTS" = success
test "$DOCS" = success
test "$ORACLE" = success
test "$WINDOWS_FXC" = success
test "$WINDOWS_STARTUP" = success
fi
wasm:
@@ -463,4 +203,4 @@ jobs:
run: |
. "$HOME/.cargo/env"
export CRAFT_FONTS_DIR="$PWD/craft-fonts"
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
cargo check --all-targets --locked --target riscv64gc-unknown-linux-gnu
+134 -10
View File
@@ -12,26 +12,150 @@ on:
permissions:
contents: read
actions: write
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
publish:
name: Request the combined docs, web, and Flatpak deployment
build:
name: Build Flatpak repository
runs-on: ubuntu-24.04
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.solvecraft
steps:
- name: Deploy through the shared Pages workflow
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
published="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" \
--jq 'select(.draft == false and .published_at != null) | .tag_name')"
test "$published" = "$RELEASE_TAG"
gh workflow run pages.yml --repo "$GITHUB_REPOSITORY" --ref main \
-f release_tag="$RELEASE_TAG"
echo "Requested the combined Pages deployment for $RELEASE_TAG" >>"$GITHUB_STEP_SUMMARY"
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="solvecraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/solvecraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/solvecraft-$version-linux-$arch.flatpak"
done
flatpak build-update-repo \
--title="SolveCraft" \
--comment='Official SolveCraft releases' \
--description='Official Flatpak repository for SolveCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/solvecraft.flatpakrepo" <<META
[Flatpak Repo]
Title=SolveCraft
Comment=Official SolveCraft releases
Description=Official Flatpak repository for SolveCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft Flatpak repository</title></head><body><main><h1>SolveCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="solvecraft.flatpakrepo">Add the SolveCraft repository</a></p><pre>flatpak remote-add --if-not-exists solvecraft $base_url/flatpak/solvecraft.flatpakrepo
flatpak install solvecraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>SolveCraft downloads</title></head><body><main><h1>SolveCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
-47
View File
@@ -1,47 +0,0 @@
# Optional, run by hand: rebuild the reference cases whose expected values come from FreeCAD
# (tools/freecad-oracle/) and show what changed. FreeCAD runs as a separate program on the runner;
# it is not a dependency of SolidCraft. Nothing is committed: review the diff and the uploaded
# files, then commit them in a pull request if the change is intended.
name: FreeCAD reference values
on:
workflow_dispatch:
inputs:
cases:
description: "Case directories (space separated); empty = every case whose source is FreeCAD"
type: string
default: ""
permissions:
contents: read
jobs:
regenerate:
name: Regenerate FreeCAD expected values
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install FreeCAD
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends freecad
freecadcmd --version || true
- name: Rebuild the cases
env:
CASES: ${{ inputs.cases }}
run: |
if [ -z "$CASES" ]; then
CASES=$(grep -l '"source": *"freecad' tests/oracle/*/expected.json | xargs -n1 dirname | tr '\n' ' ')
fi
# shellcheck disable=SC2086
tools/freecad-oracle/run.sh --write $CASES
- name: Show the changes
run: git diff --stat && git diff -- tests/oracle/*/expected.json
- name: Upload the regenerated files
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: freecad-expected
path: |
tests/oracle/*/expected.json
tests/oracle/*/reference.stl
+8 -20
View File
@@ -6,9 +6,9 @@ name: Packaging lint
on:
push:
branches: [main]
paths: [".github/workflows/**", "packaging/**", "assets/**"]
paths: [".github/workflows/**", "packaging/**"]
pull_request:
paths: [".github/workflows/**", "packaging/**", "assets/**"]
paths: [".github/workflows/**", "packaging/**"]
workflow_dispatch:
permissions:
@@ -28,19 +28,13 @@ jobs:
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml jq
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "$RUNNER_TEMP" actionlint
- name: actionlint
run: '"$RUNNER_TEMP/actionlint" -color'
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: Nightly release selection regression
run: python3 packaging/tests/nightly_test.py
- name: Windows executable icon validation regression
run: python3 packaging/tests/windows_icon_test.py
- name: Flatpak desktop smoke validation regression
run: python3 packaging/tests/flatpak_test.py
- name: PowerShell
shell: pwsh
run: |
@@ -52,14 +46,8 @@ jobs:
}
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout assets/platforms/windows/solidcraft.wxs assets/platforms/windows/solidcraft.en-us.wxl assets/platforms/macos/*.plist* assets/platforms/linux/*.xml*
desktop-file-validate assets/platforms/linux/ai.storyteller.solidcraft.desktop
# The window's X11 class is its app id; the old id stays only as the rename shims.
grep -qx 'StartupWMClass=ai.storyteller.solidcraft' assets/platforms/linux/ai.storyteller.solidcraft.desktop
grep -q '<id>ai.storyteller.solvecraft</id>' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in
grep -qF -e '--end-of-life-rebase=' packaging/flatpak-site.sh
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solidcraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solidcraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solidcraft' and m['command'] == 'solidcraft'"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/snap/snapcraft.yaml')); assert m['name'] == 'solidcraft' and m['confinement'] == 'strict' and m['apps']['solidcraft']['command'] == 'usr/bin/solidcraft'"
python3 -m py_compile packaging/linux/snap-smoke.py
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
desktop-file-validate packaging/linux/ai.storyteller.solvecraft.desktop
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.solvecraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.solvecraft.metainfo.xml"
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.solvecraft.bundle.yml')); assert m['id'] == 'ai.storyteller.solvecraft' and m['command'] == 'solvecraft'"
+4 -23
View File
@@ -1,5 +1,5 @@
# The documentation site on GitHub Pages: the book (docs/book) at /solidcraft/ and the web app
# at /solidcraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# The documentation site on GitHub Pages: the book (docs/book) at /solvecraft/ and the web app
# at /solvecraft/app/. Needs Settings → Pages → Source = GitHub Actions; skipped while the
# repository is private.
name: Pages
@@ -7,12 +7,6 @@ on:
push:
branches: [main]
workflow_dispatch:
inputs:
release_tag:
description: "Published release for Flatpak (empty: latest stable release)"
type: string
required: false
default: ""
permissions:
contents: read
@@ -48,27 +42,14 @@ jobs:
- name: Build the book
run: cargo xtask book
- name: Build the web app
working-directory: apps/solidcraft-web
run: trunk build --release --public-url /solidcraft/app/
- name: Test the web app offline and across an update (headless Chrome)
run: cargo xtask web-offline-test --site dist/web
working-directory: apps/solvecraft-web
run: trunk build --release --public-url /solvecraft/app/
- name: Assemble the site
run: |
mkdir -p site
cp -r target/book/. site/
cp -r dist/web site/app
- uses: actions/configure-pages@v5
id: pages
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Include the published Flatpak repository
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release_tag || '' }}
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: bash packaging/flatpak-site.sh site
- uses: actions/upload-pages-artifact@v3
with:
path: site
+31 -204
View File
@@ -3,26 +3,18 @@
# Push to `release` to build packages and create/update a DRAFT GitHub Release. The version
# comes from [workspace.package] in Cargo.toml. An agent verifies the artifacts and publishes
# the draft separately; see docs/releasing.md. Tags and pushes to main do not trigger releases.
# At midnight Eastern, changed main commits build and publish a nightly pre-release.
# A version with a suffix (0.2.0-rc.1) is a pre-release. Nothing is signed yet.
#
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .tar.gz packaging/linux
# FreeBSD x86_64 .tar.gz (built in a FreeBSD VM) packaging/freebsd
# macOS universal .dmg, and the CLI as a .zip packaging/macos
# Windows x64, x86, arm64 .msi and a portable .zip; x64 also the plain .exe packaging/windows
# Web a .zip of the static site packaging/web
#
# The main installers are also attached without the version in their names
# (solidcraft-windows-x64.msi, solidcraft-linux-x86_64.AppImage, ...) so that
# releases/latest/download/<name> links in the README keep working across releases.
#
# A dry run: dispatch with draft=false (the default). Only the release branch may create drafts.
name: Release
on:
schedule:
- cron: '0 0 * * *'
timezone: America/New_York
push:
branches: [release]
workflow_dispatch:
@@ -47,30 +39,17 @@ jobs:
version:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.changed.outputs.build }}
version: ${{ steps.v.outputs.version }}
date: ${{ steps.v.outputs.date }}
prerelease: ${{ steps.v.outputs.prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- id: changed
name: Skip unchanged nightly builds
env:
GH_TOKEN: ${{ github.token }}
run: bash packaging/nightly.sh
- id: v
if: steps.changed.outputs.build == 'true'
name: Resolve version
env:
REQUEST_DRAFT: ${{ inputs.draft || false }}
run: |
. packaging/env.sh
if [ "$GITHUB_EVENT_NAME" = schedule ]; then
# Unique SemVer without committing daily version bumps to main.
VERSION="${VERSION%%-*}-nightly.$(TZ=America/New_York date +%Y%m%d).$GITHUB_RUN_NUMBER.$GITHUB_RUN_ATTEMPT"
fi
if ! printf '%s' "$VERSION" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$'; then
echo "::error::Cargo.toml must contain a version like 1.2.3 or 1.2.3-rc.1"
exit 1
@@ -81,13 +60,11 @@ jobs:
fi
case "$VERSION" in *-*) pre=true ;; *) pre=false ;; esac
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
echo "### SolveCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
icons:
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -95,10 +72,10 @@ jobs:
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: release-icons
- run: cargo build --release --locked -p solidcraft-cli
- run: cargo build --release --locked -p solvecraft-cli
- run: |
. packaging/env.sh
make_icons target/release/solidcraft-cli icons
make_icons target/release/solvecraft-cli icons
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: icons
@@ -108,7 +85,6 @@ jobs:
linux:
name: Linux ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
runs-on: ${{ matrix.runner }}
strategy:
@@ -118,8 +94,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -139,22 +115,17 @@ jobs:
- name: Check the packages
run: |
dpkg-deb --info dist/release/*.deb
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solidcraft$|usr/bin/solidcraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
# Compatibility shims (docs/compatibility.md): the old CLI name and the package rename.
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft-cli$'
dpkg-deb --field dist/release/*.deb Replaces Conflicts Provides | grep -F solvecraft
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
rpm -qip dist/release/*.rpm
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solidcraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solidcraft
rpm -qlp dist/release/*.rpm | grep -E '/usr/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
tar -tzf dist/release/*.tar.gz | grep -E '/bin/solvecraft$|THIRD-PARTY-LICENSES.txt'
ldd target/release/solvecraft
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
# Legacy AppImage update alias (docs/compatibility.md).
ls dist/release/solvecraft-*-linux-*.AppImage.zsync
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-linux-${{ matrix.arch }}
@@ -164,7 +135,6 @@ jobs:
flatpak:
name: Flatpak ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
# build here. 24.04 for a newer flatpak-builder than 22.04 ships.
runs-on: ${{ matrix.runner }}
@@ -175,8 +145,8 @@ jobs:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -186,11 +156,11 @@ jobs:
- name: Install flatpak-builder
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common xvfb xauth weston dbus-x11
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
- name: Build the Flatpak bundle
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
run: packaging/linux/flatpak-bundle.sh "dist/linux/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-flatpak-${{ matrix.arch }}
@@ -199,69 +169,15 @@ jobs:
snap:
name: Snap ${{ matrix.arch }}
needs: [version, linux]
if: needs.version.outputs.build == 'true'
# Repackages the Linux job's tarball, like the Flatpak. On 24.04 because the core24 base is
# Ubuntu 24.04, which lets snapcraft build on the runner itself (--destructive-mode).
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x86_64, runner: ubuntu-24.04 }
- { arch: aarch64, runner: ubuntu-24.04-arm }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-linux-${{ matrix.arch }}
path: dist/linux
- name: Install snapcraft and the smoke-test tools
run: |
sudo snap install snapcraft --classic
sudo apt-get update
sudo apt-get install -y --no-install-recommends xvfb xauth weston dbus-x11
- name: Build the snap
run: |
sudo --preserve-env=SOLIDCRAFT_VERSION,SOLIDCRAFT_BUILD_DATE env "PATH=$PATH" \
packaging/linux/snap-pack.sh --destructive-mode "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
sudo chown -R "$(id -u):$(id -g)" dist target
- name: Install it and start the app on headless X11 and Wayland
run: |
snap_file="dist/release/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.snap"
sudo snap install --dangerous "$snap_file"
snap info --verbose "$snap_file" | head -n 12
snap connections solidcraft
solidcraft.cli --version
# Snaps expect the login session's runtime directory, which runners don't create.
sudo install -d -m 700 -o "$(id -u)" -g "$(id -g)" "/run/user/$(id -u)"
python3 packaging/linux/snap-smoke.py --out snap-smoke
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: snap-smoke-${{ matrix.arch }}
path: snap-smoke/*.png
if-no-files-found: error
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-snap-${{ matrix.arch }}
path: dist/release/*.snap
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
if: needs.version.outputs.build == 'true'
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
runs-on: ubuntu-latest
timeout-minutes: 180
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build and package in a FreeBSD VM (tar.gz)
@@ -273,7 +189,7 @@ jobs:
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
prepare: |
pkg install -y bash git curl pkgconf libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
@@ -283,10 +199,10 @@ jobs:
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
# runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
export CARGO_TARGET_DIR=/var/tmp/solvecraft-target CARGO_BUILD_JOBS=4
bash packaging/freebsd/package.sh
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
tar -tzf dist/release/solvecraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/solvecraft$|/bin/solvecraft-cli$|/share/applications/ai\.storyteller\.solvecraft\.desktop$|/share/doc/solvecraft/THIRD-PARTY-LICENSES\.txt$'
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-freebsd-x86_64
@@ -296,11 +212,10 @@ jobs:
macos:
name: macOS universal
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
@@ -320,7 +235,6 @@ jobs:
windows:
name: Windows ${{ matrix.arch }}
needs: [version, icons]
if: needs.version.outputs.build == 'true'
runs-on: windows-latest
strategy:
fail-fast: false
@@ -331,7 +245,7 @@ jobs:
# Windows on ARM: cross-compiled on the x64 runner.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
@@ -352,7 +266,7 @@ jobs:
- name: Build and package (msi, portable zip)
shell: pwsh
env:
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
@@ -363,10 +277,9 @@ jobs:
web:
name: Web (wasm)
needs: version
if: needs.version.outputs.build == 'true'
runs-on: ubuntu-latest
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -389,9 +302,8 @@ jobs:
if-no-files-found: error
release:
name: GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
if: needs.version.outputs.build == 'true'
name: Draft GitHub Release or dry-run artifacts
needs: [version, linux, flatpak, freebsd, macos, windows, web]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -407,66 +319,10 @@ jobs:
pattern: release-*
path: artifacts
merge-multiple: true
- name: Version-less copies for permanent download links
# The README links to releases/latest/download/<name>, which must not change between
# releases: solidcraft-<version>-windows-x64.msi is also uploaded as
# solidcraft-windows-x64.msi, and so on for the main installers.
working-directory: artifacts
run: |
shopt -s nullglob
for f in "solidcraft-$VERSION-"*; do
case "$f" in
*.msi | *-portable.zip | *.dmg | *.AppImage | *.deb | *.rpm | *.flatpak | *.snap)
cp -- "$f" "solidcraft-${f#"solidcraft-$VERSION-"}" ;;
esac
done
for f in solidcraft-windows-x64.msi solidcraft-macos-universal.dmg \
solidcraft-linux-x86_64.AppImage solidcraft-linux-x86_64.deb solidcraft-linux-x86_64.rpm; do
test -s "$f" || { echo "::error::missing version-less copy $f"; exit 1; }
done
ls -l solidcraft-[!0-9]*
- name: Checksums and notes
run: |
(cd artifacts && sha256sum -- * | tee SHA256SUMS.txt)
sed "s/{version}/$VERSION/g" .github/release-notes.md >notes.md
- name: Sign the checksum list for in-app updates
# Optional until the UPDATE_SIGNING_KEY secret exists (docs/releasing.md, "Update
# signing"). The trusted comment names the release, which the app checks.
env:
UPDATE_SIGNING_KEY: ${{ secrets.UPDATE_SIGNING_KEY }}
run: |
# The key the app checks against (crates/ui-egui/src/update/install.rs, PUBLIC_KEY).
public_key=$(sed -n 's/^pub const PUBLIC_KEY: Option<&str> = Some("\([^"]*\)");/\1/p' crates/ui-egui/src/update/install.rs)
if [ -z "$UPDATE_SIGNING_KEY" ]; then
if [ -n "$public_key" ]; then
echo "::error::the app expects signed updates (PUBLIC_KEY is set) but the UPDATE_SIGNING_KEY secret is missing"
exit 1
fi
echo "UPDATE_SIGNING_KEY isn't set: SHA256SUMS.txt stays unsigned"
exit 0
fi
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends minisign
umask 077
printf '%s\n' "$UPDATE_SIGNING_KEY" >"$RUNNER_TEMP/update.key"
minisign -S -s "$RUNNER_TEMP/update.key" -m artifacts/SHA256SUMS.txt \
-x artifacts/SHA256SUMS.txt.minisig -t "SolidCraft v$VERSION" </dev/null
rm -f "$RUNNER_TEMP/update.key"
if [ -n "$public_key" ]; then
minisign -V -P "$public_key" -m artifacts/SHA256SUMS.txt -x artifacts/SHA256SUMS.txt.minisig
fi
- name: Publish nightly pre-release
if: github.event_name == 'schedule'
working-directory: artifacts
run: |
tag="v$VERSION"
sha256sum --check SHA256SUMS.txt
# Stage all packages before publishing. Published tags are never replaced.
gh release create "$tag" --draft --prerelease --latest=false \
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
--notes-file ../notes.md --generate-notes -- *
gh release edit "$tag" --draft=false --prerelease --latest=false
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
- name: Create or update the draft release
if: github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)
working-directory: artifacts
@@ -492,47 +348,18 @@ jobs:
while IFS= read -r asset; do
if [ -n "$asset" ]; then gh release delete-asset "$tag" "$asset" --yes; fi
done <<<"$assets"
gh release edit "$tag" --draft --title "SolidCraft v$VERSION" \
gh release edit "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
gh release upload "$tag" --clobber -- *
else
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
gh release create "$tag" --draft --title "SolveCraft v$VERSION" \
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
fi
{
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
echo "Draft **SolveCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
echo
sed 's/^/ /' SHA256SUMS.txt
} >>"$GITHUB_STEP_SUMMARY"
# Snap Store: nightlies go to the edge channel and release drafts to candidate, only after
# every package built and the GitHub release was staged. Publishing the GitHub release
# promotes candidate to stable (snap-store.yml). Dry runs never upload. Skipped until the
# owner adds the SNAPCRAFT_STORE_CREDENTIALS secret (docs/releasing.md).
- id: snapstore
name: Check for Snap Store credentials
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
echo "configured=true" >>"$GITHUB_OUTPUT"
else
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: skipping the Snap Store upload."
fi
- name: Upload the snaps to the Snap Store
if: >-
steps.snapstore.outputs.configured == 'true' && (github.event_name == 'schedule' ||
(github.ref == 'refs/heads/release' && (github.event_name == 'push' || inputs.draft)))
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
working-directory: artifacts
run: |
sudo snap install snapcraft --classic
channel=candidate
if [ "$GITHUB_EVENT_NAME" = schedule ]; then channel=edge; fi
for arch in x86_64 aarch64; do
snapcraft upload --release="$channel" "solidcraft-$VERSION-linux-$arch.snap"
done
echo "Uploaded the snaps to the Snap Store's $channel channel." >>"$GITHUB_STEP_SUMMARY"
- name: Keep the files (dry run)
if: github.event_name == 'workflow_dispatch' && !inputs.draft
run: cp notes.md artifacts/release-notes.md
-40
View File
@@ -1,40 +0,0 @@
# Publishing a GitHub release (not a prerelease) promotes the snaps that release.yml uploaded to
# the Snap Store's candidate channel to stable, so `sudo snap install solidcraft` gets it.
# Skipped until the owner adds the SNAPCRAFT_STORE_CREDENTIALS secret; see docs/releasing.md.
name: Snap Store
on:
release:
types: [released]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: snap-store
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
steps:
- id: creds
name: Check for Snap Store credentials
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
if [ -n "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
echo "configured=true" >>"$GITHUB_OUTPUT"
else
echo "No SNAPCRAFT_STORE_CREDENTIALS secret: nothing to promote."
fi
- name: Promote candidate to stable
if: steps.creds.outputs.configured == 'true'
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
sudo snap install snapcraft --classic
snapcraft status solidcraft
snapcraft promote solidcraft --from-channel=latest/candidate --to-channel=latest/stable --yes
snapcraft status solidcraft
-77
View File
@@ -1,77 +0,0 @@
# Publishing a GitHub release (not a prerelease) opens a pull request at microsoft/winget-pkgs
# that adds the new version of bherbruck.SolidCraft, so `winget install bherbruck.SolidCraft`
# and `winget upgrade` get it. Komac (https://github.com/russellbanks/Komac) downloads the three
# versioned MSIs, reads their product codes and copies the rest from the previous manifest.
# Skipped until the owner adds the WINGET_TOKEN secret, and while the package isn't in
# winget-pkgs yet; see docs/releasing.md.
name: WinGet
on:
release:
types: [released]
workflow_dispatch:
inputs:
tag:
description: Published release tag to submit (e.g. v0.11.1)
required: true
permissions:
contents: read
concurrency:
group: winget
cancel-in-progress: false
env:
PACKAGE_ID: bherbruck.SolidCraft
KOMAC_VERSION: 2.16.0
KOMAC_SHA256: 7d2707fa6210f2789a3702de49fbd150b736dbf426ee0b9bc8e098736f9fd82d
jobs:
submit:
runs-on: ubuntu-latest
steps:
- id: check
name: Check for the token and the package
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
if [ -z "$WINGET_TOKEN" ]; then
echo "No WINGET_TOKEN secret: nothing to submit."
exit 0
fi
case "$TAG" in
v[0-9]*.[0-9]*.[0-9]*-*|*nightly*) echo "$TAG is a prerelease: not submitted."; exit 0 ;;
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::Unexpected tag '$TAG'"; exit 1 ;;
esac
if ! gh api "repos/microsoft/winget-pkgs/contents/manifests/b/bherbruck/SolidCraft" --silent; then
echo "$PACKAGE_ID isn't in winget-pkgs yet: nothing to update."
exit 0
fi
echo "version=${TAG#v}" >>"$GITHUB_OUTPUT"
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
- name: Install Komac
if: steps.check.outputs.version != ''
run: |
archive="komac-${KOMAC_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
curl -fsSLo "$RUNNER_TEMP/$archive" \
"https://github.com/russellbanks/Komac/releases/download/v${KOMAC_VERSION}/$archive"
echo "$KOMAC_SHA256 $RUNNER_TEMP/$archive" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" komac
- name: Open the winget-pkgs pull request
if: steps.check.outputs.version != ''
env:
GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }}
VERSION: ${{ steps.check.outputs.version }}
TAG: ${{ steps.check.outputs.tag }}
run: |
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/$TAG"
"$RUNNER_TEMP/komac" update "$PACKAGE_ID" --version "$VERSION" \
--urls "$base/solidcraft-$VERSION-windows-x64.msi" \
"$base/solidcraft-$VERSION-windows-x86.msi" \
"$base/solidcraft-$VERSION-windows-arm64.msi" \
--release-notes-url "https://github.com/${GITHUB_REPOSITORY}/releases/tag/$TAG" \
--submit
+41 -91
View File
@@ -1,22 +1,17 @@
# SolidCraft — instructions for agents
# SolveCraft — instructions for agents
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section.
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
recipes and measurements).
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
@@ -25,47 +20,34 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
`plan/` is gitignored (local only).
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers).
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
artwork, help text, tooltips, templates or materials, and never commit files produced by
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
holds ids and names, nothing else). File formats come from public specifications. **Never read,
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every UI icon is drawn in collected code
(`assets/icons/ui/source/`, compiled through `crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
@@ -79,44 +61,16 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
## Showing and following through on changes
- Help reviewers see what is broken or changed. For observable CAD, UI or model behavior,
issues and PRs must include actual descriptive screenshots or a short recording from a
small reproduction. This includes kernel, geometry, assembly and backend changes whose
effects can be shown in the app. Coding agents should run the app and capture this evidence
themselves; do not ask a human for evidence the agent can produce. Show the actual failure
or result, preferably before and after, with brief reproduction steps, expected/actual
behavior and annotations that point out the relevant detail.
- Flowcharts and Mermaid diagrams cannot substitute for screenshots or recordings of visible
behavior. Use diagrams for architectural reasoning when actual screenshots cannot illustrate
the point. Changes with no useful observable behavior do not need screenshot or video evidence.
- Use a small public-safe example and check for private designs, paths, account details or
other sensitive content before sharing. Attach media to the issue/PR or link to an accessible
recording; do not commit generated screenshots or videos just to provide review evidence.
- Throughout the work and before handoff, check current upstream commits and the relevant
PR's merge status. Keep your branch current with upstream merges, resolve conflicts without
discarding others' work, and re-run validation when integration changes the result. Use
`git pull --rebase origin main` before pushing as above; never force-push. Report the checked
upstream/PR state, any conflicts resolved and the validation performed. If a PR remains open,
keep monitoring it while the task is active and make its pending status clear at handoff;
do not claim it merged without checking.
## Application releases
Development pushes are not application releases. Follow `docs/releasing.md` when asked to
prepare or ship a release: choose the version from the actual changes, update Cargo.toml and
@@ -127,25 +81,21 @@ version bumps solely from commit prefixes, overwrite published versions, or push
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
`ui.render` or `solvecraft-cli snapshot`.
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
with Fusion's `measure.json` (writes `docs/oracle.md`).
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
`cargo xtask oracle` recomputes `docs/oracle.md`.
+19 -132
View File
@@ -4,161 +4,48 @@ Every non-code asset in this repository (images, icons, fonts, example parts) is
its author, source and licence. `cargo xtask assets` (part of `cargo xtask ci`) fails if an asset
file is missing from this table.
**Policy (mandatory):** SolidCraft contains **no Autodesk iconography, images, artwork, fonts,
templates, materials or help text.** Every asset is original work by SolidCraft contributors or
**Policy (mandatory):** SolveCraft contains **no Autodesk iconography, images, artwork, fonts,
templates, materials or help text.** Every asset is original work by SolveCraft contributors or
third-party material under an open licence. Screenshots of Autodesk software are never committed.
Editable drawing code, native projects and shipped exports are collected under `assets/`.
Generated-in-code assets are original and have no file to list:
- the UI icon set (`crates/ui-egui/src/icons.rs`);
- the view cube, navigation bar and timeline glyphs (`crates/ui-egui/src/viewport.rs`,
`crates/ui-egui/src/timeline.rs`).
## Files
| File | Author | Source | Licence |
|---|---|---|---|
| examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 |
| examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 |
| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid-dark.svg | SolidCraft contributors | original blue perspective grid with elliptical radial alpha, exported from VectorCraft for the Dark appearance | MIT OR Apache-2.0 |
| assets/icons/app/macos/source/SolidCraft.icon/Assets/03-solid-blue-cutout.svg | SolidCraft contributors | original saturated blue CAD solid with transparent topology cutouts, edited and exported with VectorCraft | MIT OR Apache-2.0 |
| assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/archive/icons/macos-v1/exports/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/archive/icons/macos-v1/exports/icon.svg from VectorCraft | MIT OR Apache-2.0 |
| assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| assets/archive/icons/macos-v1/exports/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/archive/icons/macos-v1/exports/icon.png | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolidCraft contributors | screenshot of SolidCraft itself (`solidcraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/demo-0.8.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.8 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding; the engraved text is set in the system's DejaVu Sans Bold), generated with examples/demo/record_ui.sh and its original animated pointer overlay (9× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.gif | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon (2× playback) | MIT OR Apache-2.0 |
| docs/screenshots/rover-build.mp4 | 0xlillith (model); SolidCraft contributors (recording) | screen recording of the actual SolidCraft desktop UI replaying the timeline of the SELENE rover (`Rover-recorded.solidcraft` from https://github.com/0xlillith/solidcraft-rover, an original model by 0xlillith built through SolidCraft MCP); the model's lettering follows Ubuntu Light outlines (Canonical Ltd., Ubuntu Font Licence 1.0, as noted in that repository); the emblem is the SolidCraft project icon | MIT OR Apache-2.0 |
| assets/branding/web/source/og.png | SolidCraft contributors | original 1280 × 640 social preview card (the SolidCraft mark, name and tagline), served by the docs site for link previews | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolidCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolidCraft contributors | rendered by `cargo xtask book` (`solidcraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
| assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in assets/fonts/biz-udp-gothic/OFL.txt; used for web UI Japanese font | SIL OFL 1.1 |
| tests/oracle/17-loft/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/17-loft/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/23-bracket-multifeature/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/23-bracket-multifeature/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/28-blind-pocket-fillets/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/28-blind-pocket-fillets/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/30-fillet-after-shell/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/30-fillet-after-shell/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/31-fillet-l-all-edges/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/31-fillet-l-all-edges/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/34-chamfer-then-fillet/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/34-chamfer-then-fillet/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/35-fillet-curved-curved/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/35-fillet-curved-curved/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/36-corner-blend-two-radii/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/36-corner-blend-two-radii/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/37-pyramid-apex-blend/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/37-pyramid-apex-blend/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/39-tee-pipe/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/39-tee-pipe/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/41-tube-radial-holes/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/41-tube-radial-holes/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/42-overlapping-patterns/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/42-overlapping-patterns/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/47-draft-curved-pocket/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/47-draft-curved-pocket/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/51-loft-rect-circle-offset/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/51-loft-rect-circle-offset/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/56-holes-on-curved-face/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/56-holes-on-curved-face/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/58-phone-stand/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/58-phone-stand/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/59-enclosure-base/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/59-enclosure-base/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/61-shaft-keyway/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/61-shaft-keyway/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
| assets/icons/app/linux/exports/hicolor/128x128/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/16x16/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/24x24/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/256x256/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/32x32/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/48x48/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/512x512/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/linux/exports/hicolor/64x64/apps/ai.storyteller.solidcraft.png | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/app_icon.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/app/shared/source/windows_ico.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/app/windows/exports/solidcraft-executable.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/app/windows/exports/solidcraft.ico | SolidCraft contributors | Generated from assets/icons/app/shared/source/app_icon.rs with the original CLI/ICO encoders | MIT OR Apache-2.0 |
| assets/icons/ui/source/glyphs.rs | SolidCraft / VectorCraft contributors | Original glyphs; caption geometry adapted from VectorCraft titlebar.rs (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
| assets/icons/ui/source/icons.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/markers.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/navigation.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/icons/ui/source/pointer.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/de.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/en.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/fr.json | rlartot | French translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/uk.json | Igor Bronovskyi (BrunIF) | Ukrainian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/zh.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/materials/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.desktop | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.metainfo.xml.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/linux/ai.storyteller.solidcraft.mime.xml | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/macos/Info.plist.in | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/macos/README.txt | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.en-us.wxl | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/platforms/windows/solidcraft.wxs | PhotoCraft / SolidCraft contributors | PhotoCraft packaging templates (https://github.com/storytold/photocraft), adapted for SolidCraft | Apache-2.0 |
| assets/shaders/viewport.wgsl | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/ui/palettes.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/styles/web/loading.css | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| docs/book/src/og.png | SolidCraft contributors | copy of assets/branding/web/source/og.png staged by cargo xtask book (gitignored) | MIT OR Apache-2.0 |
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
| docs/screenshots/demo.gif | SolveCraft contributors | screen recording of SolveCraft itself driven by examples/demo (3x speed) | MIT OR Apache-2.0 |
| docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/revolve.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/revolve.json`; gitignored) | MIT OR Apache-2.0 |
| docs/book/src/generated/sheet-metal.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/sheet-metal.json`; gitignored) | MIT OR Apache-2.0 |
| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
## Code adapted from sibling projects
| SolidCraft file | Adapted from | Licence |
| SolveCraft file | Adapted from | Licence |
|---|---|---|
| xtask/src/layers.rs | CADCraft `xtask/src/layers.rs` (dependency layering rules and tests) | MIT OR Apache-2.0 |
| apps/solidcraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
| apps/solvecraft/src/control_server.rs | CADCraft `apps/cadcraft/src/control_server.rs` (loopback JSON-lines server) | MIT OR Apache-2.0 |
| crates/ui-egui/src/lib.rs (screenshot queue, synthetic input) | CADCraft `crates/ui-egui/src/lib.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/control.rs (method dispatch and screenshot saving) | CADCraft `crates/ui-egui/src/control.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/gpu.rs (paint-callback structure) | CADCraft `crates/ui-egui/src/gpu.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/server.rs, crates/mcp/src/backend.rs (JSON-RPC framing, MCP lifecycle, control-port bridge) | CADCraft `crates/mcp/src/{server,backend}.rs` | MIT OR Apache-2.0 |
| crates/mcp/src/tools.rs (tool schema helpers and argument checking) | CADCraft `crates/mcp/src/tools.rs`, GridCraft `crates/mcp/src/tools.rs` | MIT OR Apache-2.0 |
| crates/io/src/threemf.rs (bounded zip part reading and writing) | GridCraft `crates/xlsx/src/package.rs` | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solidcraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| apps/solidcraft/src/open_documents.rs (macOS: files opened from Finder and the Dock, its test) | VectorCraft `apps/vectorcraft/src/open_documents.rs` (https://github.com/storytold/vectorcraft) | MIT OR Apache-2.0 |
| crates/ui-egui/src/titlebar.rs (title bar drag, caption buttons, resize zones, their tests) and the window setup in apps/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.rs` | MIT OR Apache-2.0 |
| LICENSE-MIT, LICENSE-APACHE (licence text) | CADCraft | — |
| .github/workflows/release.yml, .github/workflows/packaging-lint.yml and packaging/ (release pipeline: Linux AppImage/deb/rpm/Flatpak/tar.gz, FreeBSD, macOS dmg, Windows MSI/portable zip, web zip; AppRun, nfpm, Flatpak, WiX and Info.plist templates) | PhotoCraft (https://github.com/storytold/photocraft) `.github/workflows/{release,packaging-lint}.yml`, `packaging/` | Apache-2.0 |
## Incremental HDR codec adapter
`crates/render/src/lighting/hdr.rs` adapts the scanline/component/old-RLE decoding
state machines from the Radiance decoder in `image` 0.25.10
([source](https://docs.rs/crate/image/0.25.10/source/src/codecs/hdr/decoder.rs)),
MIT OR Apache-2.0. Metadata parsing still uses that library. The adapter makes row
work cooperative and adds checked run lengths, zero-run rejection, width validation,
header/input/radiance bounds and cancellation. Original licence texts are retained
in THIRD-PARTY-LICENSES.txt; no images or artwork from that project are copied.
## Recovered contributions
| 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 |
## Vendored third-party code
| Directory | Source | Licence | Changes |
|---|---|---|---|
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: parameter searches kept inside the surface domain (vendor/README.md) |
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on (vendor/README.md) |
| vendor/matext4cgmath | matext4cgmath 0.1.0, https://github.com/ricosjp/matext4cgmath (RICOS Co. Ltd.) | Apache-2.0 | root refinement stops after 100 Newton steps (vendor/README.md) |
## Vendored renderer compatibility fix
`vendor/egui-wgpu` is egui-wgpu 0.36.2 from https://github.com/emilk/egui
(MIT OR Apache-2.0; both original licence texts are included). Rust sources and
its original `egui.wgsl` shader are retained; no images or other artwork are copied.
SolidCraft's narrow patch selects a safe 1x MSAA path on the chosen OpenGL adapter
and shares that effective count with the surface attachments and CAD callbacks.
Vulkan, DirectX 12, Metal and WebGPU retain their requested multisampling.
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; trace macro (vendor/README.md) |
+41 -68
View File
@@ -1,22 +1,17 @@
# SolidCraft — instructions for agents
# SolveCraft — instructions for agents
SolidCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application:
sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Projects published by
[storytold](https://github.com/storytold) can serve as references for implementation conventions.
Any code adapted from other projects (MIT OR Apache-2.0) is noted in ATTRIBUTION.md.
## Product name
SolidCraft everywhere: crates `solidcraft-*`, binaries `solidcraft` and `solidcraft-cli`,
`.solidcraft` designs, the `solidcraft/N` format tag, `SOLIDCRAFT_*` environment variables,
settings and recovery folders, the MCP server name, docs, CI and packaging. The old name
survives only in the explicit compatibility shims listed in `docs/compatibility.md`; don't add
new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pages
`/solidcraft/`; do not add permanent old-name Pages aliases.
SolveCraft is a clean-room, open-source, pure-Rust parametric 3D CAD application in the style of
Autodesk Fusion: sketches with a constraint solver, a parametric timeline of features, a B-rep
kernel, and a desktop UI that agents can drive. Siblings with the same conventions live next to
it in `storytold/`: `cadcraft` (AutoCAD-style, the closest relative), `photocraft`, `vectorcraft`,
`gridcraft`, `designcraft`, `wordcraft`, `deckcraft`, `soundcraft`. Repos don't share code; when
code is adapted from a sibling (MIT OR Apache-2.0) it is noted in ATTRIBUTION.md.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md`
and the relevant `plan/architecture.md` section.
and the relevant `plan/architecture.md` section. Behaviour reference: `plan/fusion/`
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
recipes and measurements).
2. Kernel and library decisions: `plan/adr/0001-geometry-kernel.md` (what we may depend on, what
is rejected for licence reasons).
3. Follow the autonomous operation protocol (`plan/execution-plan.md` §6). Don't stop to ask
@@ -25,47 +20,34 @@ new uses of it anywhere else. Public docs/web/Flatpak deployments use project Pa
`plan/` is gitignored (local only).
## Never crash
People trust SolidCraft with their designs; a crash loses their work. **This outranks feature work.**
People trust SolveCraft with their designs; a crash loses their work. **This outranks feature work.**
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`,
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`). The two exceptions, in the app crate
(which denies it instead), have no safe API: two functions in
`apps/solidcraft/src/open_documents.rs` (macOS hands Finder files only to an Objective-C
method) and the Windows-only `apps/solidcraft/src/native_crash.rs` (the exception filter that
records crashes inside native code such as graphics drivers).
`unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature
returns an error ("not supported yet: …"); it never panics.
- **Input-derived numbers are hostile** (command parameters, design files, control-channel
input): `get()` not `[i]`, finite checks, caps on sizes and loop counts.
- **Third-party kernel calls are guarded:** everything that calls `truck` goes through
`solidcraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
- **Last-resort guard:** `Session::execute` runs every command under `catch_unwind`; an escaped
panic restores the design and reports `EngineError::Internal`.
- **Crash reports:** the desktop app writes every panic and every unclean exit to a local crash folder,
with the design and the commands that reproduce it (`solidcraft_engine::crash`, docs/crash-reports.md).
When someone says the app crashed, look there first: `solidcraft-cli crashes`, `crashes show <id>`,
`crashes replay <id>` (or the `diagnostics.crash_reports` command / MCP `crash_reports` tool).
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`); a
crash report's `design.solidcraft` + `replay.json` is the test's starting point.
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
- Every production crate root carries
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
## Non-negotiables
- **Independent.** SolidCraft is independent. Behaviour is designed from public documentation,
general CAD conventions and user feedback. Reference tests use analytic values and open-source
tools. Never copy another product's code, icons, artwork, help text, tooltips, templates or
materials. File formats come from public specifications.
- **No commercial CAD products.** Agents must not drive, script, automate or extract data from
commercial CAD products (Fusion, SolidWorks, Onshape, …) for this project: no test data,
measurements, command lists, ids or files produced with them.
- **Never read, copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs),
SolveSpace, CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. (`tools/freecad-oracle/`
runs FreeCAD as a separate program through its public scripting API to compute reference values;
nothing from FreeCAD is linked, vendored or shipped.) The sketch solver and the blend
- **Clean-room.** Fusion may be *observed* black-box (its UI, its toolbar dump, parts built in it
and their measurements, kept under `plan/fusion/`, never committed). Never copy Autodesk icons,
artwork, help text, tooltips, templates or materials, and never commit files produced by
Fusion. Command ids and command names are used as identifiers only (`xtask/data/fusion-catalog.tsv`
holds ids and names, nothing else). File formats come from public specifications. **Never read,
copy or link GPL/LGPL/AGPL code**: OpenCASCADE, FreeCAD (incl. planegcs), SolveSpace,
CadQuery/OCP, LibreCAD, QCAD — don't even read their sources. The sketch solver and the blend
operations are our own.
- **Assets — absolutely essential.** SolidCraft contains **no iconography, images, fonts or
artwork from commercial CAD products — ever.** Every icon is drawn in code
(`crates/ui-egui/src/icons.rs`). Any file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of commercial CAD software are never committed.
- **Assets — absolutely essential.** SolveCraft contains **no Autodesk iconography, images,
fonts or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`). Any
file asset must be original or openly licensed and **must have a row in ATTRIBUTION.md**
(`cargo xtask assets` enforces it). Screenshots of Autodesk software are never committed.
Breaking this rule is the most serious mistake you can make in this repo.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
(`CommandSpec`: id = our own dotted, lower-case id `<area>.<thing>[.<variant>]` such as
@@ -79,18 +61,13 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.** Only `crates/kernel` may use `truck`.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.start(id)`. Colours come from `theme::Tokens`.
- **Rust only.** Units are millimetres and radians internally; Z is up. The one exception is
JavaScript the browser requires: the service worker `apps/solidcraft-web/sw.js` (browsers only
run service workers as JavaScript) and the small inline scripts in `apps/solidcraft-web/index.html`.
Keep them minimal; `cargo xtask web-offline-test` covers the service worker.
- **Rust only.** Units are millimetres and radians internally; Z is up.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests,
assets, layers). Commit after every feature arc that builds.
- **Publishing**: `origin` is github.com/bherbruck/solidcraft. Changes land through **pull requests**:
push a branch, open a PR, and let GitHub CI run. Maintainers (and agents working for them) may
merge their own PR as soon as CI is green (`gh pr merge --squash`); a trivial fix (docs, typos)
may be merged right away. Keep each PR to one topic, rebase or merge `main` into it when it
conflicts, and never force-push `main`. Never commit `plan/`, build dirs (`target*/`) or
generated reference data; check `git ls-files` before pushing.
- **Publishing**: `origin` is github.com/bherbruck/solvecraft (private). After each commit that
passes `cargo xtask ci`: `git pull --rebase origin main` (re-run ci if it brought changes),
then `git push`. Never force-push. Never commit `plan/`, build dirs (`target*/`) or anything
produced by Fusion (oracle STEP/STL/PNG); check `git ls-files` before pushing.
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
(`cargo xtask wasm`, part of `cargo xtask ci`).
@@ -104,25 +81,21 @@ version bumps solely from commit prefixes, overwrite published versions, or push
before the draft is verified.
## Running and looking at the app
- `cargo run --release -p solidcraft -- --sample --control PORT` (sample design + control channel).
- `cargo run --release -p solvecraft -- --sample --control PORT` (sample design + control channel).
Pick a free port. Without a display, run it under `Xvfb :99` with `DISPLAY=:99`.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`). The first
request on a connection carries the launch's token as `"token"`: read it from
`~/.local/share/solidcraft/control/PORT.token`, or start the app with
`SOLIDCRAFT_CONTROL_TOKEN=$(openssl rand -hex 32)` and send that.
- Drive it with JSON lines on `127.0.0.1:PORT` (see `docs/control-protocol.md`):
- `{"id":1,"method":"engine.execute","params":{"command":"solid.extrude","params":{"distance":20}}}`
- `{"id":2,"method":"ui.start","params":{"command":"sketch.create"}}` then `ui.click {x, y}`
- `{"id":3,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}` then read the PNG.
- **For UI work, look at the result** (screenshot, read the PNG). Without a window use
`ui.render` or `solidcraft-cli snapshot`.
- Headless: `solidcraft-cli run script.json --out part.step`, `solidcraft-cli eval design.solidcraft`,
`solidcraft-cli snapshot script.json --out shot.png`, `solidcraft-cli commands`.
- Reference tests: `cargo xtask oracle` replays `tests/oracle/*/recipe.json` and compares with
each case's `expected.json` (analytic or FreeCAD values; writes `docs/oracle.md`). Expected
values never come from a commercial CAD product.
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
`ui.render` or `solvecraft-cli snapshot`.
- Headless: `solvecraft-cli run script.json --out part.step`, `solvecraft-cli eval design.solvecraft`,
`solvecraft-cli snapshot script.json --out shot.png`, `solvecraft-cli commands`.
- Fusion oracle: `cargo xtask oracle` replays `plan/fusion/oracle/*/recipe.json` and compares
with Fusion's `measure.json` (writes `docs/oracle.md`).
- Build with `CARGO_TARGET_DIR=<repo>/target`. Windows: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## Roadmap
`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a
milestone task lands. `cargo xtask features` regenerates `docs/features.md` (SolidCraft's
commands by area, from the registry) when commands are added or renamed.
`ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a
milestone task lands. `cargo xtask parity` recomputes command parity in `docs/parity.md`;
`cargo xtask oracle` recomputes `docs/oracle.md`.
+29 -120
View File
@@ -1,7 +1,7 @@
# Contributing to SolidCraft
# Contributing to SolveCraft
SolidCraft is an open-source parametric 3D CAD app written in pure Rust.
You sketch on a plane, constrain the sketch, turn profiles into solids, and
SolveCraft is an open-source parametric 3D CAD app written in pure Rust, in the style of
Autodesk Fusion. You sketch on a plane, constrain the sketch, turn profiles into solids, and
change a parameter to watch the timeline rebuild. It runs as a desktop app (Windows, Linux), in
the browser (wasm + WebGPU/WebGL2), headless from a CLI, and as an MCP server that AI agents can
model with.
@@ -12,57 +12,21 @@ needed most.
## Get it running (about 10 minutes on a fast machine)
You need stable Rust 1.95 or newer (required by egui/eframe 0.36). For the web
You need stable Rust 1.90 or newer. For the web
build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](https://trunkrs.dev).
```sh
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
git clone https://github.com/bherbruck/solvecraft && cd solvecraft
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo xtask test # unit, engine and UI-scenario tests
cargo xtask ci # everything a PR must pass (see below)
```
SolidCraft is the product, repository, Cargo package and executable name.
Build the desktop app with `cargo build --release -p solidcraft`, or the CLI with
`cargo build --release -p solidcraft-cli`. Run the CLI with
`cargo run --release -p solidcraft-cli -- commands` (or `target/release/solidcraft-cli commands`
after building). The old name appears only in the compatibility shims listed in
[docs/compatibility.md](docs/compatibility.md); don't add new uses of it.
Web: `cd apps/solidcraft-web && trunk serve --release`, then open the printed URL (`?sample`
Web: `cd apps/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
opens the sample part, `?webgl` forces WebGL2). Saving and opening files in the browser needs
HTTPS or localhost, because it uses the Origin Private File System.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
### NixOS: `nix develop`
NixOS has no `/usr/lib`, so nothing is on `LD_LIBRARY_PATH` and a plain `cargo run` finds none of
the libraries the app loads at run time: the Wayland ones winit opens the window through, the EGL
loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-desktop-portal
through (a dialog then never appears and the pick returns no path):
```
ERROR solidcraft: winit EventLoopError: ... The wayland library could not be loaded
ERROR eframe::native::run: Exiting because of error: WGPU error: Failed to create surface for any enabled backend: {}
ERROR rfd::backend::xdg_desktop_portal::portal::libdbus: Can't connect to a portal: libdbus-1.so not found
```
`nix develop` (the [`flake.nix`](flake.nix) in the repository root) supplies Wayland,
X11, EGL, Vulkan-loader and D-Bus runtime libraries. It also exposes the NixOS
driver manifests in `/run/opengl-driver/share`, so Vulkan can discover the system
drivers. Enable `hardware.graphics.enable` on NixOS; other Linux distributions
may need a matching [nixGL wrapper](https://github.com/nix-community/nixGL).
The default development shell retains the Rust/C toolchain already on `PATH`;
`nix develop .#rust` supplies the pinned toolchain and builds in `target/nix`.
To launch, use `nix run .#solidcraft -- --sample`. To install the app's terminal
commands, desktop entry and generated icons, use `nix profile install .#solidcraft`.
`nix build` alone creates a build result; it does not install a launcher in your
desktop menu. The installed `solidcraft` command runs the wrapped app; `solidcraft-cli` is included as
well. See
[Nix installation and troubleshooting](docs/nix.md) for remote-flake commands,
desktop integration and how to distinguish an old executable on `PATH`.
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
## How the code is laid out
@@ -82,36 +46,29 @@ The [README](README.md#architecture) has the layer table. The short version:
commands.
- `crates/ui-egui`: the desktop/web front end (egui + wgpu). It's swappable; nothing below it
depends on egui.
- `crates/mcp`, `apps/solidcraft`, `apps/solidcraft-cli`, `apps/solidcraft-web`.
- `crates/mcp`, `apps/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
## Rules that aren't negotiable
1. **Independent.** SolidCraft is independent. Behaviour is designed from public
documentation, general CAD conventions and user feedback. Reference tests use analytic values
and open-source tools. We don't drive, script, automate or extract data from commercial CAD
products, and we never copy another product's code, icons, artwork, help text, tooltips,
templates or materials. Command ids are our own (`solid.fillet`).
1. **Clean-room.** We observe Fusion as a black box: its UI, how parts it builds measure, and
what it does with a given input. We never copy Autodesk code, icons, artwork, help text,
tooltips, templates or materials, and we never commit files Fusion produced. Command ids are
our own (`solid.fillet`, not Fusion's internal names).
2. **No GPL/LGPL/AGPL code, not even reading it.** That rules out OpenCASCADE, FreeCAD
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolidCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. (Running an
open-source application to get reference values for a test is fine; reading its source is
not.) Permissive
(including planegcs), SolveSpace, CadQuery/OCP, LibreCAD and QCAD. SolveCraft is
MIT OR Apache-2.0, and its solver and blends are written from the maths. Permissive
dependencies are fine. Note which one you're adding and why in the PR, and run
`cargo xtask licences` so THIRD-PARTY-LICENSES.txt lists it (CI checks that it is current).
Nightly/manual CI also scans advisories, bans and sources with cargo-deny; see
[the local command and coverage limits](docs/book/src/dev/testing.md#dependency-advisories).
3. **Every asset is original or openly licensed and has a row in
[ATTRIBUTION.md](ATTRIBUTION.md).** Icons are drawn in code (`crates/ui-egui/src/icons.rs`).
`cargo xtask assets` enforces this.
4. **Never crash.** People trust a CAD app with their work.
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
There's no `unsafe` outside `apps/solidcraft/src/open_documents.rs` (macOS file-open events)
and `apps/solidcraft/src/native_crash.rs` (Windows native crash capture), and the compiler
denies it.
There's no `unsafe`, and clippy denies it.
- An unsupported case returns an error ("not supported yet: …"); it never panics.
- Every call into truck goes through `solidcraft_kernel::guard`.
- Every call into truck goes through `solvecraft_kernel::guard`.
- Every crash fix comes with a regression test.
5. **Everything is a command, with a test.** A new feature means a `CommandSpec` in
`crates/engine/src/cmd/`, engine tests that measure the result (volume, area,
@@ -126,7 +83,7 @@ the most complete description of the house style.
|---|---|---|
| Unit and engine tests | next to the code, or `*_tests.rs` files | `cargo xtask test` (or `cargo test --workspace --profile ci`) |
| UI scenarios | `crates/ui-egui/tests/scenarios/*.json` | part of `cargo test` |
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (see below) |
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
**UI scenarios** drive the real app headless: start a tool, click a plane or a sketch point,
press keys, then assert on the result.
@@ -145,10 +102,6 @@ press keys, then assert on the result.
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
(it can then merge right away), and remove that line in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so
@@ -157,38 +110,15 @@ in the PR. `cargo xtask test` builds the tests with it and then runs every test
`cargo xtask test -- shell`. Tests run beside each other on a busy machine, so a test must not
depend on wall-clock timing.
**CI shards the tests.** One job builds them into a cargo-nextest archive and four jobs run a
quarter each, so every test runs in its own process: don't rely on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run on every push to
`main` and nightly; check the `main` run after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
`tests::robustness_goldens` (pull requests that touch `crates/kernel` run its `sample_*` part),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
**The action matrix** tries every ordinary action on every face, edge, vertex and body of a
corpus of designs against a checked-in ratchet ([docs/action-matrix.md](docs/action-matrix.md)).
A pull request that makes an action pass should record it with `cargo xtask action-matrix --bless`;
one that makes a passing action fail fails the sample every CI run checks.
**The reference tests** are 76 parts in [`tests/oracle/`](tests/oracle/), each a `recipe.json`
in our own format with an `expected.json` beside it. SolidCraft rebuilds the recipe and compares
body count, volume, area, centre of mass and bounding box (and, where there is a `reference.stl`,
the surface itself). Expected values are either worked out in closed form
(`tools/oracle-analytic/`) or computed by building the same recipe in FreeCAD, run as a separate
program (`tools/freecad-oracle/`); each file says which. [docs/oracle.md](docs/oracle.md) has
the current results, and CI runs `cargo xtask oracle` on every pull request. A case that fails
because of a known bug names its issue in `expected.json` (`"known_failure": "#123"`); never
change an expected value to match SolidCraft's output.
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
SolveCraft rebuilds the same recipe and compares volume, area and topology counts with Fusion's
measurements; [docs/oracle.md](docs/oracle.md) has the current results. The recipes and
measurements live outside the repo (`plan/`, gitignored), because they come from a Fusion
install. If you work on geometry and want to check against it, ask in an issue.
## Sending a change
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solidcraft/issues), labelled
1. Work is tracked in [GitHub issues](https://github.com/bherbruck/solvecraft/issues), labelled
by area (`area:kernel`, `area:sketch`, `area:doc`, `area:ui`, `area:infra`). Before starting,
comment on the issue so it gets the `in progress` label and two people don't build the same
thing. For anything bigger than a bug fix that has no issue yet, open one first.
@@ -198,36 +128,15 @@ change an expected value to match SolidCraft's output.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
Help reviewers see what the words mean. For observable CAD, UI or model behavior, please
include actual descriptive screenshots or a short recording in the issue or PR. This evidence
is required for changes or bugs that can be demonstrated in the app, including kernel,
geometry, assembly and backend work. Coding agents should run a small reproduction and
capture the evidence themselves; please don't ask a human for evidence the agent can produce.
Show the actual failure or result, preferably before and after, with brief steps and
expected/actual behavior. An arrow or annotation can help identify the detail that matters.
Flowcharts and Mermaid diagrams cannot replace evidence of visible behavior. Diagrams are
useful for architectural reasoning when actual screenshots cannot illustrate the point.
Changes with no useful observable behavior do not need screenshot or video evidence. Use a
small example without private designs or sensitive details; attach the media or link to an
accessible recording rather than committing generated review artifacts.
Keep checking upstream commits and the relevant PR's merge status throughout the work and
before handoff. Integrate upstream merges, resolve conflicts without losing others' changes,
and validate the integrated result. Before pushing, run `git pull --rebase origin main`, re-run
CI if it brought changes, and push without force. Report the upstream/PR state you checked,
any conflicts resolved and validation results. While a task remains active, follow an open
PR through its status changes; at handoff, clearly identify anything still awaiting merge.
Much of SolidCraft so far was written by AI coding agents (Claude) working in parallel, each
Much of SolveCraft so far was written by AI coding agents (Claude) working in parallel, each
owning one area, with a human setting direction and reporting bugs. Their working notes are in
[AGENTS.md](AGENTS.md). Human contributors are just as welcome. Use whatever tools you like, as
long as the change meets the rules above.
## Where help is wanted most
See the [`help wanted`](https://github.com/bherbruck/solidcraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solidcraft/labels/good%20first%20issue) labels.
See the [`help wanted`](https://github.com/bherbruck/solvecraft/labels/help%20wanted) and
[`good first issue`](https://github.com/bherbruck/solvecraft/labels/good%20first%20issue) labels.
In order of how much they'd help users:
1. **Fillet and blend robustness.** Fillets are our own code (truck has no fillet operation).
@@ -236,7 +145,7 @@ In order of how much they'd help users:
faces, and fillets that consume neighbouring faces.
2. **Boolean robustness.** A random-design fuzzer currently gets about half of its designs
through without a kernel error. Each failing seed is a reproducible bug.
3. **Y-up / Z-up.** SolidCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
3. **Y-up / Z-up.** SolveCraft is Z-up only, so most STEP files (from SolidWorks, Onshape, or
Fusion's default) open lying on their backs. Fusion solves this with a "default modeling
orientation" preference.
4. **Imported appearances.** Colours from STEP files are lost when a feature changes the
@@ -247,4 +156,4 @@ In order of how much they'd help users:
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
of SolidCraft. SolidCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
of SolveCraft. SolveCraft isn't affiliated with Autodesk. Fusion is a trademark of Autodesk, Inc.
Generated
+70 -773
View File
File diff suppressed because it is too large. Load diff
+17 -40
View File
@@ -5,38 +5,30 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.11.0"
version = "0.1.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
authors = ["SolidCraft contributors"]
repository = "https://github.com/bherbruck/solidcraft"
rust-version = "1.90"
authors = ["SolveCraft contributors"]
repository = "https://github.com/bherbruck/solvecraft"
[workspace.dependencies]
# internal
solidcraft-geom = { path = "crates/geom" }
solidcraft-sketch = { path = "crates/sketch" }
solidcraft-kernel = { path = "crates/kernel" }
solidcraft-render = { path = "crates/render" }
solidcraft-gpu = { path = "crates/gpu" }
solidcraft-doc = { path = "crates/doc" }
solidcraft-io = { path = "crates/io" }
solidcraft-engine = { path = "crates/engine" }
solidcraft-ui-egui = { path = "crates/ui-egui" }
solidcraft-mcp = { path = "crates/mcp" }
solidcraft-derive = { path = "crates/derive" }
solvecraft-geom = { path = "crates/geom" }
solvecraft-sketch = { path = "crates/sketch" }
solvecraft-kernel = { path = "crates/kernel" }
solvecraft-render = { path = "crates/render" }
solvecraft-doc = { path = "crates/doc" }
solvecraft-io = { path = "crates/io" }
solvecraft-engine = { path = "crates/engine" }
solvecraft-ui-egui = { path = "crates/ui-egui" }
solvecraft-mcp = { path = "crates/mcp" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
# derive macros (crates/derive); already in the tree through serde_derive
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
sha2 = "0.10"
semver = "1"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
truck-modeling = "0.6"
@@ -44,21 +36,16 @@ truck-shapeops = { version = "0.4", default-features = false }
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
truck-stepio = { version = "0.3", default-features = false }
truck-topology = "0.6"
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0); already in the
# tree through truck-meshalgo.
spade = { version = "2.15", default-features = false, features = ["std"] }
rayon = "1.12"
# packages (3MF)
zip = { version = "3", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
quick-xml = "0.38"
# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust)
ttf-parser = { version = "0.25", default-features = false, features = ["std"] }
# images
image = { version = "0.25", default-features = false, features = ["png", "hdr"] }
png = "0.18.1"
image = { version = "0.25", default-features = false, features = ["png"] }
# ui
egui = "0.36"
@@ -66,10 +53,6 @@ eframe = { version = "0.36", default-features = false, features = ["persistence"
# Instant/SystemTime that also work in the browser (std panics on wasm32-unknown-unknown).
web-time = "1"
egui-wgpu = "0.36"
# the wgpu egui-wgpu uses, for compute (crates/gpu); backends come from the apps' features
wgpu = { version = "30", default-features = false, features = ["std", "wgsl"] }
naga = { version = "30", default-features = false }
pollster = "0.4"
rfd = "0.17"
[workspace.lints.rust]
@@ -108,16 +91,10 @@ opt-level = 2
opt-level = 3
lto = "thin"
codegen-units = 16
# Line tables only: crash reports name file:line in their backtraces (no runtime cost; a larger
# binary). See docs/crash-reports.md.
debug = "line-tables-only"
debug = 0
# truck-shapeops with SolidCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
# truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
[patch.crates-io]
truck-shapeops = { path = "vendor/truck-shapeops" }
truck-stepio = { path = "vendor/truck-stepio" }
truck-meshalgo = { path = "vendor/truck-meshalgo" }
matext4cgmath = { path = "vendor/matext4cgmath" }
# Keep OpenGL pipelines, resolve textures and CAD callbacks at the same safe sample count.
egui-wgpu = { path = "vendor/egui-wgpu" }
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 SolidCraft contributors
Copyright (c) 2026 SolveCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+7 -13
View File
@@ -1,5 +1,5 @@
SolidCraft
Copyright (c) 2026 SolidCraft contributors.
SolveCraft
Copyright (c) 2026 SolveCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
@@ -7,10 +7,10 @@ Third-party material
Software dependencies are listed in Cargo.lock; their licences are in THIRD-PARTY-LICENSES.txt
(`cargo xtask licences`), which the programs also carry (Help > About > Licences,
`solidcraft-cli licences`). The geometry kernel builds on the truck crates
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
(truck-modeling, truck-shapeops, truck-meshalgo, truck-stepio, truck-topology and their
dependencies, https://github.com/ricosjp/truck), licensed under the Apache License 2.0 and used
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolidCraft
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolveCraft
modifications (listed in vendor/README.md and marked in the source); its licence is in
vendor/truck-shapeops/LICENSE. Copyright (c) RICOS Co. Ltd.
@@ -19,15 +19,9 @@ licensed MIT OR Apache-2.0; ATTRIBUTION.md lists the files.
The release packaging (.github/workflows/release.yml, packaging/) is adapted from PhotoCraft
(https://github.com/storytold/photocraft), licensed under the Apache License 2.0.
The incremental Radiance RGBE row adapter in crates/render/src/lighting/hdr.rs adapts
decoding state machines from image 0.25.10 (https://github.com/image-rs/image),
MIT OR Apache-2.0. ATTRIBUTION.md describes the bounded/cancellable changes; the
original licence texts are included in THIRD-PARTY-LICENSES.txt.
None of the bundled assets come from third parties: every icon is drawn in code. The per-file
list is in ATTRIBUTION.md.
Most bundled assets are drawn in code or contributed originally; third-party fonts (Liberation Sans
for sketch text, BIZ UDPGothic for web Japanese display) are licensed under SIL OFL 1.1. The
per-file list is in ATTRIBUTION.md.
SolidCraft is an independent project and is not affiliated with, sponsored by or endorsed by
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
Autodesk, Inc. Autodesk and Fusion are trademarks of Autodesk, Inc.; they are used here only to
describe compatibility goals.
+49 -186
View File
@@ -1,200 +1,75 @@
# SolidCraft
# SolveCraft
**An open-source parametric 3D CAD application built in pure Rust.**
**Parametric 3D CAD; an open-source, [clean-room](CLAUDE.md) reimplementation of Autodesk
Fusion 360, built in pure Rust.**
Sketch, constrain, and build solids with a parametric feature timeline. Change a dimension
and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents
model through the built-in MCP server.
model through the built-in MCP server. Part of the storytold "craft" family.
![SolidCraft 0.8: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo-0.8.gif)
![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif)
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, the rim rounded in one pick, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots, "Pi 4" engraved in DejaVu Sans Bold and a PETG material defined in the design, with its mass in Properties, then height 24 → 32 mm in Parameters and it all rebuilds. 9× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.8.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
## Download
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
[web app](https://bherbruck.github.io/solvecraft/app/) to try it in the browser.
[![Download for Windows][badge-windows]][windows-msi]
[![Download for macOS][badge-macos]][macos-dmg]
[![Download for Linux][badge-linux]][linux-appimage]
[![Get it from the Snap Store][badge-snap]][snap-store]
[![Try it in your browser][badge-web]][web-app]
- **Windows:** download and run the [installer][windows-msi].
Also for [ARM64][windows-arm64-msi] and [32-bit][windows-x86-msi] PCs, or as a
[portable zip][windows-portable] that runs without installing.
With [WinGet](https://learn.microsoft.com/windows/package-manager/winget/):
`winget install bherbruck.SolidCraft`.
- **macOS:** open the [disk image][macos-dmg] and drag SolidCraft into Applications
(Apple silicon and Intel).
- **Linux:** download the [AppImage][linux-appimage], allow it to run as a program and open it.
Or install the [.deb][linux-deb] (Ubuntu, Debian, Mint) or [.rpm][linux-rpm] (Fedora,
openSUSE), or use [Flatpak](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
With Snap: `sudo snap install solidcraft` ([Snap Store][snap-store], or the [.snap file][linux-snap]).
For ARM64: [AppImage][linux-arm64-appimage], [.deb][linux-arm64-deb], [.rpm][linux-arm64-rpm].
- **Web:** [try it in your browser][web-app], no install.
The downloads aren't signed yet. On Windows, if SmartScreen warns you, choose **More info**, then
**Run anyway**. On macOS, the first time, right-click SolidCraft in Applications and choose **Open**.
Older versions, checksums and other formats are on the [releases page](https://github.com/bherbruck/solidcraft/releases).
Install details, upgrading from earlier builds and building from source are in
[Getting started](https://bherbruck.github.io/solidcraft/guide/getting-started.html).
[badge-windows]: https://img.shields.io/badge/Windows-Download-0078D6?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0yIDJoOS41djkuNUgyek0xMi41IDJIMjJ2OS41aC05LjV6TTIgMTIuNWg5LjVWMjJIMnpNMTIuNSAxMi41SDIyVjIyaC05LjV6Ii8%2BPC9zdmc%2B
[badge-macos]: https://img.shields.io/badge/macOS-Download-333333?style=for-the-badge&logo=apple&logoColor=white
[badge-linux]: https://img.shields.io/badge/Linux-AppImage-E95420?style=for-the-badge&logo=linux&logoColor=white
[badge-snap]: https://img.shields.io/badge/Snap-Snap_Store-82BEA0?style=for-the-badge&logo=snapcraft&logoColor=white
[badge-web]: https://img.shields.io/badge/Web-Try_in_browser-2EA44F?style=for-the-badge&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIj48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxwYXRoIGQ9Ik0yIDEyaDIwTTEyIDJhMTUgMTUgMCAwIDEgMCAyME0xMiAyYTE1IDE1IDAgMCAwIDAgMjAiLz48L3N2Zz4%3D
[windows-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64.msi
[windows-arm64-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-arm64.msi
[windows-x86-msi]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x86.msi
[windows-portable]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-windows-x64-portable.zip
[macos-dmg]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-macos-universal.dmg
[linux-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[linux-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.deb
[linux-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.rpm
[linux-arm64-appimage]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.AppImage
[linux-arm64-deb]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.deb
[linux-arm64-rpm]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-aarch64.rpm
[linux-snap]: https://github.com/bherbruck/solidcraft/releases/latest/download/solidcraft-linux-x86_64.snap
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://bherbruck.github.io/solidcraft/app/
**Documentation:** the [SolidCraft book](https://bherbruck.github.io/solidcraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)).
**Status: early but usable for simple parts.** 75 of 76 public reference parts
match their analytic or FreeCAD-computed volume, area, centre of mass and bounding box
([docs/oracle.md](docs/oracle.md)); the other is a tracked bug.
These checks do not establish complete geometric or workflow equivalence. The commands SolidCraft has
are listed by area in [docs/features.md](docs/features.md); status and plans are in [ROADMAP.md](ROADMAP.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solidcraft/issues),
**Status: early but usable for simple parts.** 76 of 76 reference parts built in Fusion
rebuild in SolveCraft with the same volume, area and topology ([docs/oracle.md](docs/oracle.md));
command parity with Fusion's Design workspace is tracked in [docs/parity.md](docs/parity.md).
Open work and known bugs are in [GitHub issues](https://github.com/bherbruck/solvecraft/issues),
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
## Built with SolidCraft
<a href="https://github.com/0xlillith/solidcraft-rover"><img src="docs/screenshots/rover-build.gif" alt="SELENE, a scientific rover by 0xlillith, assembling itself as the timeline plays through its 3,323 steps in SolidCraft: tub, wheels, electronics, suspension, sampling arm, solar wings, dish and camera turret" loading="lazy"></a>
*SELENE, a scientific rover built entirely through SolidCraft's MCP server by
[@0xlillith](https://github.com/0xlillith/solidcraft-rover): 3,323 editable timeline steps that
rebuild with no errors, replayed here at 2× speed.
[Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/rover-build.mp4)
· [download the editable model](https://github.com/0xlillith/solidcraft-rover/releases/tag/v1.0.0).*
## What works
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
projection, trimming, fillets and chamfers, including corners at the origin.
New face attachments retain supported construction identities and chosen occurrences;
missing sources expose an explicit plane repair workflow. See [face attachments](docs/face-attachments.md).
Explicitly closed fit splines and closed associative offsets use periodic C2 interpolation.
Supported convex line-and-arc offsets retain their identity when an inset consumes corner
arcs, and linked extrusions follow the current boundary. Breaking the association freezes
the boundary and keeps existing extrusions usable.
Curve drawing and picking adapt together to zoom and display scale, including small circles
at Fit and sketches in placed components.
- **Constraints and dimensions** solved by our own solver. Points the constraints fix relative
to known geometry are placed exactly by construction (loci intersections and rigid-cluster
decomposition, after Owen 1991 and Fudos–Hoffmann 1997); Levenberg–Marquardt solves the rest,
and the rank of its Jacobian gives the remaining degrees of freedom. Constraints: coincident,
point on curve, horizontal/vertical, parallel, perpendicular, tangent, curvature (G2), equal,
concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
point-to-line, radius, diameter, angle, arc sweep, arc length and slot length dimensions driven
by parameters. Over-constraining edits are rejected; fully constrained geometry turns black.
Dragging solves the sketch every frame and rebuilds the model once, when the drag ends.
Select a line or its two endpoints, type its dimension and press Enter to commit one undo
step; with Sketch Dimension it's D, click, type, Enter. Fit keeps sketch geometry clear of the palette and navigation controls.
- **Sketches** on the origin planes, offset planes or planar faces: lines, rectangles (2-point,
3-point, centre), circles (centre, 2-point, 3-point), arcs (3-point, centre), polygons, slots
and points; construction geometry.
- **Constraints and dimensions** solved by our own solver (damped Gauss–Newton with
degree-of-freedom analysis): coincident, horizontal/vertical, parallel, perpendicular, tangent,
equal, concentric, collinear, midpoint, symmetry, fix; linear, aligned, horizontal, vertical,
radius, diameter and angle dimensions driven by parameters. Over-constraining edits are
rejected; fully constrained geometry turns black.
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
Newly captured complete adaptive boundaries retain their outer and hole source identities
when spline span counts change. Partial or ambiguous boundaries do not qualify.
- **Features** on a parametric timeline: extrude, revolve, sweep, loft, fillet/chamfer,
shell, draft, holes, threads, patterns, mirror, split, combine, move and construction geometry.
The timeline rebuilds incrementally; features can be edited, suppressed, renamed, reordered
and deleted. Supported geometry and current limitations are described in [ROADMAP.md](ROADMAP.md).
New revolves preserve symmetric and two-sided angular placement across reversed axes;
older designs retain their saved convention when reopened or edited.
- **Parameters and configurations** with unit-aware expressions (`2 * width + 5 mm`,
`angle / 2`), ordered rules, feature suppression and per-row body materials/appearances.
- **Assemblies** with nested components and occurrences, joints, motion studies, contact sets,
interference and material-aware mass properties. Recorded body transfers preserve modelling
history and world placement; 3D sketch wires retain their coordinate frames through edits.
- **Sheet metal** with flanges, hems, fold/unfold/refold, material cutouts, flat patterns,
retained shallow prismatic pockets, exact line/arc/circle DXF outlines and bend tables;
plastic enclosure features and rules. Pocket depth and side survive unfold/refold and appear
separately from through-hole outlines in the flat preview and DXF.
- **Files**: design containers (`.solidcraft`) with lossless binary mesh payloads;
plain JSON remains supported for opening, Save As and export. Desktop Open can show a saved
provisional picture from warm geometry caches while rebuilding before editing; caches never
replace the parametric design. Export to STEP (AP242 with body names, colours and
- **Features** on a parametric timeline: extrude (one side, flipped, symmetric, two-sided, start
offset; new body, join, cut, intersect), revolve, fillet and chamfer (straight convex edges),
box, cylinder, sphere, torus, combine, move. The timeline rebuilds incrementally from the first
changed feature; it can be rolled back, and features can be suppressed, renamed, edited and
deleted.
- **Parameters** with unit-aware expressions (`2 * width + 5 mm`, `angle / 2`).
- **Files**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
components as assemblies; revolved faces as native planes, cylinders, cones, spheres and tori),
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ;
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
need B-rep bodies);
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a
design, or drop it on the window ([docs/step-import.md](docs/step-import.md)).
- **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the
integrated product viewport and CPU preview PNGs. Existing simple colors remain supported.
Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented.
See [opaque surfaces](docs/render-opaque-pbr.md).
- **Display meshes** from our own tessellation planner: each face becomes a plan of (u, v)
points and triangles that shares its edge nodes with its neighbours, so the mesh is watertight.
Facets follow the chord tolerance, the facet angle and a longest side, with cells at most 2:1
along straight directions. Box-bounded and four-cornered faces mesh as structured grids.
Trimmed faces are cut into four-sided patches: squares around holes, butterflies in round
faces, motorcycle traces from concave corners (after Lyon et al. 2019). Each patch then meshes
as a Coons grid. Intersection edges are evaluated by Newton on both surfaces. A compute shader
evaluates the plans for the whole scene in one dispatch, with a CPU fallback when the GPU
can't build it. Planning and evaluating the 78 reference bodies takes about 65 ms, 50–65×
faster than the previous mesher.
- **Desktop app** (egui + wgpu): toolbar with workspace tabs, browser, timeline, 3D viewport with
axis view and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
view cube and navigation bar, sketch tools, feature dialogs, command palette, and a JSON-lines
control channel so agents and tests can drive it.
Feature errors and Help can open an editable **Create Issue** draft with relevant context
and an optional reviewed screenshot; opening GitHub does not submit the report.
- **Headless CLI** `solidcraft-cli`: run command scripts, measure (volume, area, centre of mass,
face/edge/vertex counts), export, render PNG snapshots and replay reference recipes.
- **MCP server** `solidcraft-cli mcp`: AI agents model, measure and look at parts headless or
- **Headless CLI** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
face/edge/vertex counts), export, render PNG snapshots, replay Fusion oracle recipes.
- **MCP server** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
in the running app (see below).
## Build and run
```sh
cargo run --release -p solidcraft -- --sample # desktop app with a sample part
cargo run --release -p solidcraft-cli -- commands # the command registry
cargo run --release -p solidcraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solidcraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solidcraft-cli -- render examples/render-studio.json --out product.png
cargo run --release -p solidcraft -- part.step # open a STEP file as a new design
cargo run --release -p solidcraft-cli -- eval part.step # measure an imported STEP file
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
cargo run --release -p solvecraft-cli -- commands # the command registry
cargo run --release -p solvecraft-cli -- run examples/bracket.json --out bracket.step
cargo run --release -p solvecraft-cli -- snapshot examples/bracket.json --out bracket.png
cargo run --release -p solvecraft -- part.step # open a STEP file as a new design
cargo run --release -p solvecraft-cli -- eval part.step # measure an imported STEP file
cargo xtask ci # fmt, clippy, tests, assets, layers, wasm
```
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
On Windows and Linux, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
multi-axis HID interface. Move the cap to pan and zoom, and tilt or twist it to rotate the 3D view.
While editing a sketch, the cap pans and zooms without changing the sketch view. The Navigation
page in Preferences has an on/off switch and a speed control for the 3D mouse.
On Linux SolidCraft reads `/dev/hidrawN` directly, so the logged-in user needs access to the
device. A udev rule that adds the `uaccess` tag does that, for example
`/etc/udev/rules.d/70-3d-mouse.rules` for devices with 3Dconnexion's vendor id:
```
SUBSYSTEM=="hidraw", ATTRS{idVendor}=="256f", TAG+="uaccess"
```
Older SpaceNavigators use Logitech's vendor id `046d`; add a rule with their product id (see `lsusb`)
instead of matching every Logitech device. Reload with `sudo udevadm control --reload && sudo udevadm trigger`, then replug the device. The Flatpak
sandbox does not expose hidraw devices, so the 3D mouse only works with native builds there for now.
Devices that send all six axes in HID report 1 (some homemade ones do) are recognised from their
report descriptor, so they work as well.
In the browser: `cd apps/solidcraft-web && trunk build --release --public-url ./` (WebGPU, or
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
WebGL2 with `?webgl`; `?sample` opens the sample part).
## Scripts
@@ -212,14 +87,12 @@ A script is a JSON list of commands, the same ones the UI runs:
## AI agents (MCP)
`solidcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
`solvecraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
stdio. Headless it runs its own design session; with `--connect 127.0.0.1:PORT` it drives the app
started with `solidcraft --control PORT`, so you can watch the agent model. Tools:
started with `solvecraft --control PORT`, so you can watch the agent model. Tools:
`list_commands`, `execute`, `batch`, `inspect_design`, `measure`, `body_topology`,
`set_parameter`, `screenshot` (returns the image), `export`, `undo`, `redo`, `new_design`, `open`,
`save`. Tool calls reach files only in the open design's folder (when it lies inside your home
folder, never home itself or a hidden folder), SolidCraft's recovery folder and folders given with
`--allow-path DIR`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
`save`. Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. Details, client
configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Architecture
@@ -232,26 +105,16 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
| L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation |
| L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL import features |
| L4 | `engine` | the session and the command registry (everything is a command) |
| L5 | `gpu`, `ui-egui`, `mcp` | compute-shader evaluation of display tessellation plans; the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
| L5 | `ui-egui`, `mcp` | the swappable desktop front end; the MCP server (headless or bridged to the app) |
| apps | `solvecraft`, `solvecraft-cli` | desktop app, headless CLI |
<!-- ANCHOR_END: architecture -->
## Contributing
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
sketches, solid features, an editable timeline, parameters and assemblies. See the
[roadmap](ROADMAP.md) for supported operations and current limitations.
See [CONTRIBUTING.md](CONTRIBUTING.md): how to build and test, the code map, the clean-room
and licence rules, and where help is wanted most.
## Licence
MIT OR Apache-2.0, at your option. Developed under the
[clean-room contribution rules](CLAUDE.md); third-party code and assets retain their
documented licences and attribution.
SolidCraft is independent. Behaviour is designed from public documentation, general CAD
conventions and user feedback. Reference tests use analytic values and open-source tools.
SolidCraft is not affiliated with, sponsored by or endorsed by Autodesk. Autodesk and
Fusion are trademarks of Autodesk, Inc.
MIT OR Apache-2.0, at your option. SolveCraft is not affiliated with Autodesk; Fusion is a
trademark of Autodesk, Inc.
+34 -485
View File
@@ -1,15 +1,15 @@
# SolidCraft roadmap
# SolveCraft roadmap
Status as of **2026-10-14**. SolidCraft supports parametric modelling (sketch →
Status as of **2026-10-10**. SolveCraft targets Fusion-style parametric modelling (sketch →
features → parametric timeline) as an open, pure-Rust, agent-drivable application.
## At a glance
| Question | Answer |
|---|---|
| Where are we? | **M0 and M13 done; M1, M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server. |
| Features | Every registered command by area — [docs/features.md](docs/features.md) (`cargo xtask features`); registry breadth does not establish workflow or geometric depth |
| Reference tests | **82 / 82 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (61) or FreeCAD-computed (21) expected values, run by CI on every PR. |
| Where are we? | **M0, M1, M13 done; M2, M5–M7, M9, M10, M14 in progress.** Sketch with solver and the full SKETCH create/modify set; extrude, revolve (two sides, symmetric, to object), fillet/chamfer (variable radius, chord length, a radius per edge, corner patches), holes, threads (cosmetic and modelled), patterns, mirror, shell (inside/outside/both), draft, loft, sweep, coil, split body and split face, delete face, construction planes, axes and points; persistent naming of faces and edges; parameters and configurations; components, joints, motion studies, exploded views; sheet metal and a plastic enclosure subset; STEP/IGES/3MF/STL/OBJ/DXF; autosave and crash recovery; desktop and browser (wasm) app, CLI, MCP server, oracle harness. |
| Command parity (in scope) | **328 / 541 (61%)** in-scope commands; SOLID + SKETCH **185 / 291 (64%)** — [docs/parity.md](docs/parity.md) |
| Fusion oracle | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md) |
| Tests | Solver, profiles, kernel booleans/blends/measures, expressions, timeline, persistent naming, file formats and migrations, assemblies, sheet metal, engine end-to-end, hostile-input fuzz over every command and headless UI scenarios. `cargo xtask ci` reports the current passing counts. |
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
@@ -20,7 +20,8 @@ The issue batch adds preservation of blind pockets in through cuts, associative
offsets and plastic face placements, XZ (+Y normal) with a v6 migration preserving older
designs, interactive angle placement, file-picker sketch imports, Home/recent controls,
toolbar overflow, and the remaining component-frame and assembly WIP work. The enclosure
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
acceptance scenarios now include board resizes; STEP reopening is automated. Inspection in
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
In scope, in priority order:
1. SOLID and SKETCH (now).
@@ -36,255 +37,36 @@ In scope, in priority order:
metal rules (thickness, K-factor, bend radius) (M10).
5. A small PLASTIC subset for enclosures, after sheet metal: Boss, Rib, Web, Lip/Groove, Snap Fit.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need (scope proposed in #772), PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
Deferred until further notice: MESH, FORM (T-splines), SURFACE beyond what solids need, PCB / 3D
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. The parity report's
headline counts in-scope tabs only and lists the deferred ones separately.
## Milestones
| # | Milestone | Status | Hours left |
|---|---|---|---|
| M0 | Vertical slice | done | — |
| M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
| M1 | Sketch depth: done — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs | done | 4 |
| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks | in progress | 30 |
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 |
| M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 |
| M4 | Files: STEP import (done: all 29 Fusion STEP files — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (done), DXF sketches, OBJ import | in progress | 6 |
| M5 | Persistent naming of faces and edges (plan/adr/0002): faces named from their history (sketch curve sides, caps, revolve sections, primitive sides, blends, pattern/mirror copies; split pieces numbered), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. Left: kernel-reported face history (exact through coincident surfaces) | in progress | 20 |
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator; taper handle open), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearances, preferences | in progress | 6 |
| M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, exact (unfaceted) modelled threads | in progress | 12 |
| M8 | Surface workspace | deferred | — |
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table, DXF export), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option +
pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
## Physical material data
- Physical material card validation (#923/#922) provides typed SI mechanical and
thermal data, per-property sources/conditions and isotropic/orthotropic stability
checks through read-only `material.properties.validate`. It does not assign or
persist properties, provide a solver or enable rendering; see
[the contract and staged follow-ups](docs/physical-material-cards.md).
- Directional elasticity (#994) queries the validated card in material-local axes
through read-only `material.properties.directional`, with source/condition review.
It does not assign body frames, predict structural behavior or enable a solver.
## Library foundations (#785, #786, #787)
- Local scalar libraries (#846) inspect/export bounded portable catalogs and copy exact
selected appearance/material revisions into existing design definitions in one undo step.
Appearance offers local loading/search/export; physical materials use existing commands.
This slice is independent of the render stack and does not change the document format.
See [the contract and limits](docs/book/src/dev/local-libraries.md).
- Proposed phases cover offline bundled/project/user packs, physical materials and
appearances, editable wood mapping, and parameterized component/assembly families.
Stable identity/revision, copied design definitions, provenance and resource limits
apply from the first implementation. Existing material, appearance and standard-part
commands remain the application paths. See [the proposal](docs/book/src/dev/libraries.md)
for source/license review and acceptance gates. This is planning only: pack loading,
texture rendering and expanded component families are not implemented by this entry.
## Render foundation
- Opt-in opaque PBR (#1046) connects persisted surface parameters to the integrated
product viewport and CPU PNG, using a fixed direct-light studio. Geometry and mass
stay unchanged. HDRI, transmission and textures remain separate gates.
See [the consumer and limitations](docs/render-opaque-pbr.md).
- Combined integration is tracked in #842, with independent scalar libraries in
#863 and richer PBR/material-card transport in #1086. See [phase boundaries and validation gates](docs/render-integration.md).
Render/library consumers use the merged asynchronous picker (#1127). Material-card
UI (#1094), texture mapping and HDRI lighting remain explicit follow-ups.
- R4a adds bounded CPU product-image jobs, cancellation and atomic PNG export through
engine commands, the Render panel, CLI and persistent MCP. Immutable captures and
results stay outside persistence/undo; native workers and cooperative browser tasks
share the same work protocol. See [jobs, limits and export](docs/render-jobs.md).
- R3 adds an overlay-free product scene and the integrated Render workspace, with
runtime GPU host capabilities, per-document drafts and named camera/background
controls. Design keeps its camera and drawing policy. Raster transparency remains
approximate; PBR and environments are later slices. See
[workspace and scene contracts](docs/render-workspace.md).
- R2 adds bounded named render setups in document format 21 and the read/save
`render.settings.get/set` commands. Proven render-only changes preserve geometry
and prepared world data through undo/redo and checkpoint restore. This slice does
not add a Render workspace, PBR, environments or image jobs; see
[settings and refresh policy](docs/render-settings.md).
- Bounded PNG/JPEG texture admission (#900) validates in-memory sources and returns
completed RGBA8 pixels with explicit color/data roles. It is an input utility only:
ICC/16-bit normalization, local wood mapping, resource persistence and the renderer
consumer remain separate work. See [limits and licensed-file probes](docs/render-texture-input.md).
- The initial opaque surface numerical contract for #1004 provides bounded linear
color/metallic/roughness/IOR admission and an original tested direct-light BRDF.
The numerical module alone advertises no capabilities; the installed consumer above
provides rendering. See
[the model, evidence and remaining consumer gates](docs/render-surface.md).
- A bounded cooperative Radiance RGBE input utility is available in L1 render;
it does not yet enable HDRI/PBR rendering or a document import command. See
[the input contract](docs/render-hdr-input.md) and #813 for integration work.
- R1 adds renderer-independent backend/profile metadata, strict request resolution and
bounded image/scratch admission checks, exposed by the read-only `render.backends`
command. It describes the current CPU raster snapshot path without evaluating the model.
The legacy Design snapshot route remains unchanged. Later slices above provide the
workspace/jobs and opaque PBR; HDRI and optional engines remain future work. See [the contract](docs/render-backends.md)
and [the phased proposal (#620)](https://github.com/bherbruck/solidcraft/issues/620).
## Dimensioning and error reporting
- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by
`batch`: body changes and created sketch IDs stay, while repeated lists are omitted.
Both tools retain full replies by default.
- Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot,
Polygon and Spline. Each open part remembers its last interactive choice per family
across sketch sessions and document-tab switches; closing the part resets these UI choices.
- Inscribed, circumscribed and edge polygon tools preview their actual outline before
placement, including orientation and the selected side count.
- Construction (X) preserves the active sketch drawing tool, picked anchors and inline
dimension values, so line type can change while a shape is in progress.
- Planar sketch construction previews and hover feedback keep the same dashed pattern as
committed geometry; centerlines retain their dash-dot pattern.
- Point has a default Sketch CREATE toolbar button alongside the existing drawing tools.
Saved custom layouts are preserved; Reset Toolbar restores the default Point button.
- Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane
arguments reject such a name when an older design's construction plane already has it. Use
the other alias, the construction feature's numeric ID or an explicit plane; existing
stored references keep their saved meaning. Section Analysis uses the same check.
- Sketch text can be edited from a letter interior or outline's context menu or the Edit Text command.
Interior selection requires every profile boundary to belong to the same text source;
the editor highlights the whole source without replacing profile selections.
Content, baseline X/Y, height and rotation update the original text source in one undo step;
cancelling or rejecting an invalid value preserves the sketch. Position and height fields
use the document units captured when editing starts; generated outlines remain associated
with their text source, and untouched inputs preserve their saved internal values.
Dragging a text outline moves the entire source in one undo step; Escape restores the
gesture. Move/Copy on a selected text outline provides unit-aware X/Y distances and rotation
about its baseline; copies keep an independent editable text source.
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Circular-pattern and angled-plane axes reject explicitly supplied malformed or expression
origins instead of silently rotating about zero. Numeric origins remain in millimetres;
associative axis expressions and construction-axis references remain unsupported.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
and navigation controls, including enlarged displays and nested pending placements.
- Sketch annotations, live drawing lengths and Measure use document units. Inches show
three decimals and retain a meaningful fourth digit. Bare length dimension entry captures
the current design unit; changing units leaves existing geometry and expressions intact.
Component/canvas distance controls, sketch cursor coordinates, flat-pattern dimensions
and pocket depths, interference volumes and parts-library lengths also use design units.
- Curvature and Polygon constraints accept complete preselected operands. Fix/Unfix
consumes complete point/curve selections consistently; mixed or ambiguous selections
retain the picking workflow. Rejected constraints preserve selection and undo history.
- Contextual error reporting: a **Create Issue** action beside feature errors opens a
reviewable GitHub issue draft with the error, operation settings, build diagnostics and
useful reproduction context. The draft is editable, with optional capture of the actual
viewport and copy/save controls. Opening GitHub does not submit the issue. Help also exposes
reporting for unexpected results that did not produce an error.
- The header exposes **Report a bug**, including an accessible compact control on
narrow windows. It opens the same editable report and screenshot review without
changing the active model or a pending dimension.
- Crash reports: every panic (in a command, in the kernel, anywhere else) and every unclean
exit (abort, stack overflow, kill: found at the next start through a locked per-run command
journal) leaves a local report with the backtrace, version and commit, the command and the
design that reproduce it (`solidcraft-cli crashes replay <id>`). Repeats are counted, not
rewritten; at most 20 reports, 50 MB and 90 days. The app shows them after a crash (Help >
Crash Reports…) with a reviewed issue draft; agents read them through
`diagnostics.crash_reports`, the MCP `crash_reports` tool or the CLI. Nothing is sent
anywhere. See [crash reports](docs/crash-reports.md).
- Command scripts from designs (#886): `timeline.export_script` (CLI `solidcraft-cli script`)
writes a design's history as a script that `eval` and `run` replay into the same design —
user parameters, units, sheet metal and plastic rules and materials as their own commands,
then each feature through `timeline.add` with its ids, names and dimension parameters kept,
so every feature kind replays exactly and a failing feature fails the same way. Trimming the
script after a step gives the design at that step. Appearances, folders, views and
configurations are listed as left out; components and joints are not supported yet.
## 0.2.0 release batch
- Includes the new axis navigation, adaptive sketch drawing/picking, origin-corner repairs,
durable supported profile/face references, endpoint dimensioning and contextual reporting.
- Smooth ruled-wall display joins are hidden only within the supported geometric certificate;
authored splits and actual creases remain protected. Wireframe retains physical edges.
- Paste New uses a bounded current-geometry comparison for supported convex line/circle
prisms with separated circular holes; unsupported shapes retain the existing strict check.
- Common-bore Union retries check additional boundary curves before recutting the bore,
preventing the reproduced thin-web loss. Unknown additional trims refuse; inherited
common-cylinder trims retain their existing policy, so this is not a general Boolean proof.
- Exact full-path assembly joint editing remains separate work and is not part of this batch.
- Stable 0.2.0 is published: all release payload checksums, native cold reopening, deployed
web app and both Flatpak architectures were verified. The replacement README demo uses
the original electronics enclosure story and animated pointer in the actual English
0.2.0 UI, at 4× playback. Publication follows [the release workflow](docs/releasing.md).
## 0.2.1 critical fixes
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs.
- Eligible planar faces survive fillet, chamfer and shell operations with exact source
certificates. Sketches placed directly on the enclosure wall and inner floor follow
width and wall changes, including after the USB cut and cold reopening.
- Remove artificial straight-wall and rounded-corner subdivisions left by shell and
extrusion cuts. Identical generated wall segments span adjacent slabs as one physical
face, with exact full-height supports and boundary stitching. Preserve authored face
splits and face appearances. Permanent regressions cover material, STEP, reopening and
unaffected bodies across the extrusion end plane.
- Feature dialogs use the available screen height for their fields, with OK and Cancel
outside the scrolling region, including enlarged display scales.
- Design format 13 records shell-face attachments. Older designs remain readable;
newly saved designs require 0.2.1 or later. Ambiguous or unsupported faces still refuse
attachment rather than guessing a plane.
- Saved projections from a removed strip retain their geometry and report the lost
reference. Explicitly reselect the wall to repair that projection.
## View navigation
- Desktop Ctrl/Cmd +/- and 0 change or reset the saved UI scale. The scale slider keeps
its layout stable during a drag and applies the selected scale on release.
- OpenSCAD navigation preset: left drag orbits, right/middle drag pans, Shift+right/middle
drag zooms, and the wheel zooms. Available in the shared desktop/web Navigation preferences.
- Compact clickable signed X/Y/Z axis tips, smooth drag-to-orbit, and Shift-orbit snapping
to 26 standard and diagonal views. Pointer motion accumulates independently of a lightly
damped spring, with a boundary margin to prevent flicker. Z-up/Y-up and the nearest square
top/bottom heading are preserved. Diagonals include 45-degree edge and isometric corner views.
| M12 | Release: installers, signing, docs, performance | planned | 16 |
| M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Left: configuration rules, per-row materials and appearances | in progress | 4 |
| M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `<file>.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solvecraft/2"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solvecraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 |
## Known limitations
- Scheduled and manual CI scan locked dependency advisories, bans and sources with
cargo-deny. Vulnerabilities fail the scan; unmaintained crates warn. Local vendored
patches require separate upstream review because advisory databases cannot version-match
their path dependencies. Pull requests retain the existing licence checks.
- Associative shell-face sketches require a unique certified planar source. Nested shell
attachments and ambiguous coincident sources remain unsupported.
- G2 fillets: exact quintic blends of straight edges between planar faces, with one perpendicular
planar end face at each end. The UI and `solid.fillet` expose `curvature` (alias `g2`); the size
is an equivalent circular setback. Curved faces/edges, sloped ends and corner junctions remain unsupported.
- Fillets and chamfers: constant, variable-radius and chord-length fillets; a radius per edge,
rounds next to earlier rounds of another radius, and smooth corner patches where three rounds
meet (a sphere for equal radii); straight edges (convex or concave) between planar faces with perpendicular
planar end faces (equal-distance chamfers also meet sloped planar end faces, including inner
basin rims within the rim width); convex fillets also meet sloped planar ends and reflex
corners around pocket rims, with unequal wall slopes and extensions confined to the face;
connected equal-distance chamfers also trim each other across unequal sloped rim walls;
whole smooth loops of a planar face, picked whole or by one edge (tangent
planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent
chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors,
plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of
neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square;
@@ -293,264 +75,31 @@ retain their saved two-finger scroll preference; camera motion is available thro
convex planar body or of an extruded part; fillets inside patterns; closed chains of curved
edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the
blend a rational B-spline through exact arcs), closed or ending at planar faces square to the
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
between two overlapping extruded arcs); knife edges, where the two faces have opposite
normals (a wall that runs out tangent into a face: the ball that fits there); an edge that
runs out along an end face tangent to one side (the arc top edge of that wall), cut where the
blend meets it; rounds that end in an earlier round on curved faces, in any order (the rounds
of two edges at a vertex meet in a crease, as on a box), picked together or one after the
other, except a round after a round of the edge between the wall and a flat face that it
would run over; the three edges at a vertex of any smooth faces, of one radius, end in a
piece of sphere. A limiting blend that collapses a side wall into coincident boundary
curves is refused as too large rather than returning an unmeshable face (#1113).
edges.
- Booleans: prisms along one direction (plates with holes, slots, patterns of them, equal
sections stacked end to end) are combined exactly in 2D. Qualified axial cuts preserve
annular holes with overrun cutters, including inward Press Pull after a joined extrusion;
curved wall qualification rejects false transverse axes instead of filling holes.
Bodies touching along one whole face
sections stacked end to end) are combined exactly in 2D; bodies touching along one whole face
(a part and its mirror image) are stitched; coincident planar faces are pushed apart when
their neighbours are planes or cylinders. Other coincident curved configurations can still
fail. Bounded containment classification preserves fully internal cavities and nested
material islands independently of shell order, with STEP round-trip and material-membership
regressions. Near-contact curved shells use the general boolean path rather than a
containment shortcut. Spheres are built from six pole-free patches.
fail. Fully internal voids are not supported. Spheres are built from six pole-free patches.
- Mass properties come from fine tessellation (curved faces within ~1e-4 relative); triangles
that cut through curved faces (trimmed faces after booleans) are split onto the surface first.
- STEP import (our own reader, crates/kernel/src/step_in) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; analytic offsets on planes, cylinders, cones, spheres
and regular tori are read exactly (signed/nested distances, units and face sense). Freeform
offset surfaces and pcurve-only edges are not read yet,
- STEP import (our own reader, crates/kernel/src/step_in; all 29 Fusion STEP files pass
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
product names, colours and assemblies; offset surfaces and pcurve-only edges are not read yet,
and assembly components are flattened into bodies. Closed periodic faces (torus, B-spline
bands) are split in two for meshing; measure() uses a finer tolerance for small radii.
- Persistent naming follows the kernel's exact face history (docs/book/src/dev/naming.md):
references resolve by name first, follow splits and merges, and warn when the entity split,
merged or vanished, or was found by geometry only (operations that report no history yet).
New planar sketch attachments use exact box, extrusion cap/side and immutable imported-face
source certificates with checked target and author occurrence paths. A missing or changed
source fails explicitly; Redefine Sketch Plane previews reattachment or a fixed plane while
retaining current geometry and dimension identities. Unsupported modified faces need an
explicit fixed plane. Legacy attachments retain their original resolver behavior.
- Persistent naming (plan/adr/0002) names faces from their history and edges by their faces;
references resolve by name first and warn when the entity split or vanished. Kernel-reported
face history (exact through coincident surfaces) is not wired in yet.
- Modelled threads are cut as a faceted body (curved faces within 1/200 of the pitch); Extrude
and Revolve To Object on a curved face cut the tool back to the target's surface.
- Explicit sketch breaks preserve exact ellipses, fit/control splines (including high degrees)
and conics; analytic trim/break also sees free-form cutters. Free-form curves cut at a tangent
touch (no clean crossing) enter that profile as polylines.
- Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines.
Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth).
- Extend intersects exact ellipses and rational/high-degree splines without moving the line or
circular support. Associative free-form offsets have a whole-span 0.002 mm normal-distance
certificate and refine within bounded node limits while retaining existing IDs.
At the node limit, geometric certificate subdivisions accommodate unequal spline parameter
speeds without changing stored nodes or relaxing the distance tolerance (#971).
Associated offset fit nodes stay hidden from display, picking and snapping; dragging the
editable source updates the derived boundary live. Break Association restores point editing.
Singular, self-intersecting and ambiguous curves refuse atomically; mixed junctions and nonsmooth
closed natural-spline seams remain limitations. New explicitly closed fit splines and closed
offset outputs use periodic C2 interpolation, supporting repeated certified offsets; older
natural splines are preserved. Clicking the starting fit point deliberately closes a new loop.
Newly captured adaptive profile references with complete source coverage retain their outer
and hole identities through span-count changes, including parameter edits after extrusion.
Partial, intersected or ambiguous boundaries do not qualify for these whole-source references.
Legacy curve-span multisets remain exact and unchanged; already-lost selections may need
reselection. Format 12 stores the new bounded whole-boundary references.
Projected analytic boundaries follow nested loop identities through resizing, including
stationary holes and rounded corners. Supported convex, tangent line-and-arc boundaries
can lose or regain corner arcs during an inset while preserving reserved curve identities
and linked profile selections. Format 12 saves those associations; compatible older closed
offsets upgrade structurally without changing their curve IDs. Breaking an association
freezes its current boundary and materializes downstream profile references. Child offsets
of a boundary with retired arcs still require breaking its association first; mixed or
nonconvex junctions and retained references to disappearing individual arcs remain limited.
- Extrude and Revolve editors retain original direction, extent expressions, explicit targets,
axes, From/To inputs and All-profile intent. No-op edits preserve downstream dimension names
and selected occurrence frames; cancel and successful edits preserve one-step undo history.
Placed feature edits preview through the edited timeline entry, and cylindrical Emboss guides
use the sketch's actual target, author or uniquely placed third-component frame.
Revolve rotators follow the actual signed axis and support symmetric and two-sided extents;
dragging a To Object extent converts its derived value to an explicit angle. Legacy reversed-axis
symmetric geometry is preserved through format 11's explicit legacy convention. New revolves
use the sketch-normal direction for each selected profile, including To Object and curved
target trimming, independently of the selected axis sign.
- Cylindrical emboss/deboss wraps bounded orthogonal line profiles, including holes, concave
outlines and nested islands, onto complete exterior cylinders or coaxial sleeves with exact
radial walls. New features infer a unique eligible cylinder when the face field is empty;
ambiguous targets require an explicit face. Saved target
and author occurrence paths retain the chosen placements; missing/split faces refuse rather
than retarget. General free-form profiles, seams, stepped cylinders and other curved surfaces
remain unsupported. STEP round-trip and independent radial/volume checks cover this subset.
- Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not:
filleted boxes, bosses, domes, countersinks; corners where four or more planes meet, such as a
floor falling to a drain, as long as the corner's edges are all convex or all concave); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two
sections ruled.
- Sketch corner tools: fillet and all three chamfer modes pick a connected corner even when
its endpoint overlaps the fixed origin or a loose point. Ordinary drawing still snaps to
the origin; clicked UI regressions verify geometry, closed profiles and undo.
- Sketch display: drawing, picking, highlighting, tool previews and profile shading share
adaptive projected curve boundaries. Fit, zoom, display scale and placed sketch frames
invalidate the display cache without changing CAD geometry or timeline history. Work
budgets and clipped paths prevent unbounded sampling or invented edges across the eye plane.
The Sketch Palette scrolls its options on short windows and enlarged displays while
keeping constraint status and Finish Sketch visible.
- Face projection: closed boundaries have a deterministic outer-first order. Refresh matches
existing geometry before assigning positions, retaining saved curve IDs and constraints
across enumeration changes and resize, including concentric circles. Ambiguous identities
retain the last geometry and report a lost reference; permutation matching is bounded.
- Project accepts complete supported preselection in one undo step. Visible external sketch
curves and points retain their source sketch identity; model geometry, axes and planes use
the existing linked projection commands. Rejection preserves the document and selection;
mixed selections retain ordinary tool-first picking.
- Inline sketch dimensions: Tab and Shift+Tab cycle within the active shape's value boxes,
selecting visited values for replacement. Expression completion retains forward-Tab
priority; real keyboard regressions cover rectangles, lines and single-value circles.
Typed lengths, radii, line angles, signed arc sweeps and slot lengths persist as constraints
and expressions through save/reopen. Shape creation and its dimensions form one undo step;
rejected values keep the editable field focused.
- Repeated components: Browser selection, visibility, properties, copy and Move/Copy use
checked occurrence paths. Active nested occurrences author planar sketches and solids in
their selected frame. Pending moves compose through parents; dialog cancellation restores
its selected move. Source materials and body/face appearances follow transformed instances.
Shared nested definitions remain shared. Deep Paste New copies reachable definition graphs,
independent feature/input histories, styles and internal motion records, with bounded geometry
checks and explicit external-link notices. Paste New selects the copy while preserving the active
authoring context. Imported periodic STEP seams survive placement and reexport with shared
topology and appearances intact. Recorded body transfers preserve historical Join/Cut membership,
material, placements and machining data through edits, copying, undo and reopen. New 3D wires
record their coordinate frame; legacy wires migrate from known provenance without changing
coordinates. Broken legacy links with missing provenance can require explicit refresh or relink.
See [occurrence workflows](docs/occurrence-workflows.md).
- Toolbar drops on panel labels, gaps and margins append commands with insertion feedback;
button drops keep insert-before ordering.
- Parameters and Configurations have distinct actions in the SOLID toolbar's CONFIGURE panel.
Parameters opens the user/model variable editor; Configurations opens the configuration table.
Configure remains available in the panel dropdown.
- Parameter creation supports Enter with validation and continued keyboard entry. Shared
expression autocomplete supports Up/Down selection, Enter/Tab acceptance and Escape dismissal;
accepting a suggestion does not submit a parameter or trigger viewport shortcuts.
- Movable window title bars follow the app's dark/light header palette. Radial right-click
menus are opt-in in Preferences > Navigation; ordinary context menus remain the default.
- Desktop distribution: running Windows windows and executable resources use the app icon;
packaging validates icon resources. Nix installs launchers, MIME data and generated icons,
with explicit launch apps and Vulkan-loader/driver discovery. Flatpak feed instructions
register the required runtime source; release checks exercise desktop modelling on X11/Wayland.
- OpenGL rendering clamps effective anti-aliasing to 1x across UI pipelines, textures and CAD
callbacks to prevent black frames. Other backends retain the requested sample count, and
stored preferences remain unchanged.
- Configuration rules: ordered typed conditions can drive parameters and suppressions or
restrict values. Preview explains effective values without modifying the document; applying
rules validates rows atomically. Length expressions retain their authoring units across
document-unit changes. The UI supports structured All/Any/Not conditions and rule ordering.
Component styles and certified semantic face styles are available; unsupported lineage refuses
atomically, and stale certified face paint cannot jump to another surface. See [configurations](docs/configurations.md).
- Sheet-metal material: inherited holes, crossing edge notches, overlapping cuts and retained
islands share the same boundaries in flat preview, DXF and unfolded solids. Detached material
pieces survive unfold/refold as one logical sheet group, including split flanged sheets.
Cut-outs made in the flat pattern cross bends at any angle or curved: their edges fold to
helices and curves on the bend, their walls square to the sheet. Free-form bases use bounded
sampling.
Parallel prismatic partial-depth cuts retain pocket side and depth through transfer and
unfold/refold, with separate pocket contours in preview and DXF. Planar Intersect preserves
exact holes and retained islands, including the union of multiple selected profiles, and
records complementary depth layers as pockets while retaining the stock gauge. Unsupported
tapered or bend-crossing machining refuses atomically instead of restoring material later.
Sketches attach to a sheet's flat faces (base and flanges, either side) by panel, in the
flat pattern's coordinates, and follow it through rebuilds and unfold/refold.
A flange on another flange's far edge (a return) follows that flange's closed end and keeps
clear of the flange beside it, and the returns of one feature are mitred toward each other
with the rule's gap where their flanges meet at a corner; returns of separate features, and
flanges on returns, are not closed toward each other.
- Rolling-ball fillet accuracy: independent offset-cylinder tee references check surface
distance, actual trimmed tessellation, supporting-cylinder contacts and tangency at two
scales. The measured trimmed-part error is below 0.0001 mm; this is an analytic geometry
check. Under-resolved fits return an error.
- Contact driving checks the trajectory between clear endpoints, including linked joints,
playback and sparse motion exports. Checks use bounded tessellation samples, not exact
continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md).
## 3D sketch bridges
3D sketch drawing snaps to points on other visible sketch planes without flattening them onto
the active plane. Bridge endpoints capture their source positions; associative endpoint links,
construction-plane selection of 3D wires and loft guide rails remain separate work.
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Construction on 3D paths
Plane Along Path has a viewport path selector, fractional position, live preview and timeline
editing. Construction planes and points resolve drawn 3D wires without applying their sketch
plane a second time. Curved paths use sampled segments; multi-curve chains, absolute distance
and reference-object placement remain separate work, as do loft rails and centerlines.
## Design properties (#1015)
- Designs carry project, part number, revision, author, organization, description and licence
in an optional `metadata` object (File → Design Properties…, `document.metadata.*`,
`inspect_design`). STEP and IGES headers and 3MF model metadata take the fields their format
has a place for. Other tools keep their own data under a reverse-DNS key, kept as it is
through open, edit and save and never exported. See [design properties](docs/book/src/guide/files.md#design-properties).
Per-component properties (a part number per component) can follow with the same type.
## Document-opening performance
- Desktop STEP import prepares consecutive independent solids in bounded batches with up
to eight workers, retaining serial traversal, result order and persistent source identities.
The web path remains serial. Original multi-solid fixtures show 64–81% lower complete
import times; single-solid assembly parts are not scheduled across nodes. See
[measurements and limits](docs/perf.md#parallel-step-body-preparation-2026-10-12).
- Design containers (#303, phase one) separate and deduplicate STEP, raw images and
lossless binary mesh arrays while keeping plain JSON as a supported Save As/export
format. Generated-mesh storage measurements reduce serialization and decoding work;
a seekable reader defers original blobs until after provisional display.
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
- Warm saves with explicit `cache: true` can include a versioned native B-rep cache through SolidCraft-owned topology
and geometry records, with a separate truck adapter and bounded binary reader. Exact
face-source certificates and operation history survive codec round trips. A provisional
picture can be generated before original blobs are read; editing and measurement wait
for authoritative reconstruction. Cached metadata binds preview and reconstruction to
the same document despite concurrent file writes. Default saves omit this cache and never force cold
evaluation or meshing; explicit `preview: true` remains a separate warm-mesh option.
Keys, resource budgets and document/revision guards reject incompatible, hostile or
stale results. A second modelling kernel still requires its own tested adapter.
- Warm saves with explicit `cache: true` can also carry a bounded PNG thumbnail from already cached meshes.
Home reads it without rebuilding the design when its local picture is missing;
mismatched or malformed thumbnails fall back to normal generation. The image is
kernel-independent and saving never forces cold meshing to produce it.
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and
thumbnails. Straight-line closest-point projection and exact curved-surface evaluation
reuse reduce repeated work without relaxing geometry checks. Three alternating optimized
benchmark runs reduce median rebuild time by 26% on the public sensor enclosure and 22%
on a local case assembly; shape measurements and topology match the original build.
See [performance measurements](docs/perf.md). Further cold Boolean and import costs remain.
- Prepared assembly geometry survives selection and view changes while real geometry and
placement changes invalidate it. A Pi assembly edge-selection probe drops from 9.24 s to
0.57 s; initial opening is unchanged. Actual Windows desktop profiling measures 15.87 s
median opening, with 82% spent rebuilding geometry; halving full opening remains open.
## Kernel
- Successful Boolean retries report their strategy and numerical adjustments as
feature warnings and structured inspection data. Diagnostics survive cached
evaluation, undo/redo and cold reopening; the robustness runner counts retry
events by strategy and reports any bounded-log truncation. Existing geometry
acceptance checks remain in force.
- Cylinders of one radius whose axes cross (a second bore across a bore of the same size, a pipe
tee of equal pipes) touch at two points, where the mesh intersection cannot trace the curves
and a boolean cannot tell the four arms of the crossing apart. When the ordinary boolean fails
on such a pair, both faces are split along the line of their cylinder through the touching
points (the solids are unchanged), the boolean is taken of those, and its history is composed
back over the operands as given (#788). The result keeps the split lines as edges of the bore
walls. A crossing the ordinary route fails on and the split faces do not help (a radius of 17.5
in a 45 mm block with a tool of 80 mm) is still refused.
- Boolean intersection-curve construction refuses point-only, collapsed and non-finite
polylines without panicking. Direct constructor regressions retain valid open endpoints
and closed spherical intersections; the original interactive trigger remains unidentified.
- Shell of a stepped, joined planar body no longer creates horizontal subdivisions across
continuous walls. Fresh axis-aligned Cut walls are stitched using their exact construction
support and shared topology, preserving real steps and intentional boundaries. The local
reported case returns from 86 to 49 faces with unchanged material and bounds; regressions
cover stepped stock, STEP round trips and saved projections from removed pieces.
truck (Apache-2.0) behind our own `kernel` crate, with our own blend operation and boolean
retry/validation; see `plan/adr/0001-geometry-kernel.md` (local) — summary in README.
-32
View File
@@ -1,32 +0,0 @@
# Security policy
## Supported versions
SolidCraft is young and moves quickly, so only the latest release gets security fixes. Please
update to the [latest release](https://github.com/bherbruck/solidcraft/releases/latest) before
reporting, and check whether the problem is still on `main`.
| Version | Supported |
| --- | --- |
| Latest release | ✅ |
| Nightly builds | ✅ (fixes land here first) |
| Older releases | ❌ |
## Reporting a vulnerability
Please **don't open a public issue** for a security problem. Report it privately through GitHub:
open the repository's [Security tab](https://github.com/bherbruck/solidcraft/security) and choose
**Report a vulnerability**
([direct link](https://github.com/bherbruck/solidcraft/security/advisories/new)).
Please include what you found, how to reproduce it (a design file, command or MCP request), the
version and platform, and the impact you expect. I'll acknowledge the report as soon as I can,
keep you updated in the advisory, and credit you when the fix is published unless you'd rather
stay anonymous.
Areas where reports are especially useful:
- opening untrusted design, STEP, STL or 3MF files (crashes, excessive memory or CPU, path tricks)
- the MCP server and the control API (file access outside the allowed folders, command injection)
- the web app (anything reaching beyond the page's own storage)
- release packages and the update paths (AppImage, Flatpak, MSI, deb, rpm, snap)
+80 -2822
View File
File diff suppressed because it is too large. Load diff
-25
View File
@@ -1,25 +0,0 @@
[package]
name = "solidcraft-cli"
description = "SolidCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
version.workspace = true
edition.workspace = true
license.workspace = true
default-run = "solidcraft-cli"
[lints]
workspace = true
[[bin]]
name = "solidcraft-cli"
path = "src/main.rs"
# Compatibility alias for the CLI's former name: forwards to solidcraft-cli with a deprecation
# note on stderr. Shipped in every package for at least one release.
[[bin]]
name = "solvecraft-cli"
path = "src/alias.rs"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-mcp = { workspace = true }
serde_json = { workspace = true }
-30
View File
@@ -1,30 +0,0 @@
//! `solvecraft-cli`: the CLI's former name, kept for at least one release so existing scripts
//! and MCP client setups (`claude mcp add solvecraft -- solvecraft-cli mcp`) keep working.
//! Compatibility shim: it runs `solidcraft-cli` (next to this executable, else on PATH) with the
//! same arguments and standard streams, after a one-line deprecation note on stderr (stdout
//! stays clean for MCP's JSON-RPC), and exits with its status.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::process::{Command, ExitCode};
const TARGET: &str = "solidcraft-cli";
fn main() -> ExitCode {
eprintln!("solvecraft-cli is deprecated and will be removed in a later release: use solidcraft-cli");
let sibling = std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|dir| dir.join(format!("{TARGET}{}", std::env::consts::EXE_SUFFIX))))
.filter(|p| p.is_file());
let program = sibling.map(|p| p.into_os_string()).unwrap_or_else(|| TARGET.into());
match Command::new(&program).args(std::env::args_os().skip(1)).status() {
Ok(status) => match status.code() {
Some(code) => ExitCode::from(u8::try_from(code).unwrap_or(1)),
None => ExitCode::FAILURE,
},
Err(e) => {
eprintln!("solvecraft-cli: could not run {}: {e}", program.to_string_lossy());
ExitCode::FAILURE
}
}
}
-239
View File
@@ -1,239 +0,0 @@
//! `bench-agent`: a large design built the way an agent builds one over MCP, one small command
//! at a time, timed as it grows and then opened again.
//!
//! The design is a bicycle of about 1,300 bodies: frame tubes joined at root and then moved into
//! a component, two wheels whose spokes and nipples are placed one by one, a chainring with
//! patterned teeth, a cassette, a chain of individual links, cockpit, seat and a few hundred
//! fasteners. Nothing is read from a file; the script is generated here, so the benchmark needs
//! no fixture.
//!
//! ```text
//! solidcraft-cli bench-agent [--scale F] [--script-out script.json] [--save design.solidcraft]
//! ```
//! prints one JSON line per hundred commands (mean and max milliseconds, bodies so far), then the
//! build total, then the phases `bench-open` prints for the saved design.
use serde_json::{Value, json};
use solidcraft_engine::Session;
struct Script {
commands: Vec<Value>,
}
impl Script {
fn c(&mut self, command: &str, params: Value) {
self.commands.push(json!({"command": command, "params": params}));
}
/// A round tube from `a` to `b` (an agent's frame tube, spoke or post).
fn tube(&mut self, a: [f64; 3], b: [f64; 3], radius: f64, body: &str) {
let d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let axis = [d[0] / len, d[1] / len, d[2] / len];
self.c(
"solid.cylinder",
json!({"radius": radius, "height": round3(len), "base": a.map(round3), "axis": axis.map(round6), "body_name": body}),
);
}
fn component(&mut self, name: &str) {
self.c("component.create", json!({"name": name}));
}
fn root(&mut self) {
self.c("component.activate", json!({"component": "root"}));
}
/// A wheel built at the origin in its own component, then placed where it goes (the
/// component's occurrence moved), as an agent builds one part and positions it.
fn wheel(&mut self, label: &str, at: f64, spokes: usize) {
let cx = 0.0;
self.component(&format!("{label} wheel"));
self.c("solid.torus", json!({"major": 311, "minor": 12, "center": [cx, 0, 340], "body_name": format!("{label} rim")}));
self.c("solid.torus", json!({"major": 330, "minor": 18, "center": [cx, 0, 340], "body_name": format!("{label} tyre")}));
self.c("solid.cylinder", json!({"radius": 18, "height": 110, "base": [cx, -55, 340], "axis": "Y", "body_name": format!("{label} hub")}));
for k in 0..spokes {
let a = std::f64::consts::TAU * k as f64 / spokes as f64;
let side = if k % 2 == 1 { 1.0 } else { -1.0 };
let hub = [cx + 30.0 * a.cos(), side * 40.0, 340.0 + 30.0 * a.sin()];
let (ca, sa) = ((a + 0.12).cos(), (a + 0.12).sin());
let rim = [cx + 300.0 * ca, side * 4.0, 340.0 + 300.0 * sa];
self.tube(hub, rim, 1.0, &format!("{label} spoke {k}"));
let nipple = json!({"radius": 2, "height": 12, "base": rim.map(round3), "axis": [round6(ca), 0, round6(sa)], "body_name": format!("{label} nipple {k}")});
self.c("solid.cylinder", nipple);
}
self.root();
self.c("occurrence.move", json!({"occurrence": format!("{label} wheel:1"), "translate": [at, 0, 0]}));
}
}
fn round3(v: f64) -> f64 {
(v * 1e3).round() / 1e3
}
fn round6(v: f64) -> f64 {
(v * 1e6).round() / 1e6
}
/// The bicycle's command script. `scale` multiplies the counts of repeated parts (spokes,
/// chain links, fasteners); 1.0 gives about 1,300 bodies.
pub fn bicycle(scale: f64) -> Value {
let n = |k: f64| ((k * scale).round() as usize).max(1);
let mut s = Script { commands: Vec::new() };
// Frame: tubes at root, the main triangle joined, then everything moved into its component.
let (bb, head_top, head_bot, seat_top) = ([0.0, 0.0, 300.0], [600.0, 0.0, 860.0], [580.0, 0.0, 740.0], [-150.0, 0.0, 820.0]);
s.tube(bb, head_bot, 19.0, "Down tube");
s.tube(seat_top, head_top, 16.0, "Top tube");
s.tube(bb, seat_top, 16.0, "Seat tube");
s.tube(head_bot, head_top, 22.0, "Head tube");
for (side, y) in [("L", 60.0), ("R", -60.0)] {
s.tube([0.0, y * 0.3, 300.0], [-420.0, y, 340.0], 10.0, &format!("Chainstay {side}"));
s.tube([-140.0, y * 0.3, 800.0], [-420.0, y, 340.0], 9.0, &format!("Seatstay {side}"));
s.c("solid.box", json!({"length": 20, "width": 6, "height": 40, "corner": [-430.0, y - 3.0, 320], "body_name": format!("Dropout {side}")}));
}
s.c("solid.cylinder", json!({"radius": 24, "height": 80, "base": [0, -40, 300], "axis": "Y", "body_name": "BB shell"}));
s.c("solid.combine", json!({"target": "Down tube", "tools": ["Top tube", "Seat tube", "Head tube", "BB shell"]}));
s.component("Frame");
s.root();
let frame = ["Down tube", "Chainstay L", "Seatstay L", "Dropout L", "Chainstay R", "Seatstay R", "Dropout R"];
s.c("component.move_bodies", json!({"bodies": frame, "component": "Frame"}));
// Wheels: every spoke and nipple a body of its own.
s.wheel("Front", 640.0, n(36.0));
s.wheel("Rear", -420.0, n(36.0));
// Drivetrain: patterned chainring teeth, a cassette and a chain of separate links.
s.component("Drivetrain");
s.c("solid.cylinder", json!({"radius": 95, "height": 4, "base": [0, 50, 300], "axis": "Y", "body_name": "Chainring"}));
s.c("solid.box", json!({"length": 6, "width": 4, "height": 8, "corner": [-3, 50, 395], "name": "Tooth", "body_name": "Tooth"}));
s.c("solid.pattern.circular", json!({"bodies": ["Tooth"], "axis": {"origin": [0, 50, 300], "dir": [0, 1, 0]}, "count": 44}));
for g in 0..11 {
let r = 22.0 + 3.5 * g as f64;
s.c(
"solid.cylinder",
json!({"radius": r, "height": 2, "base": [-420.0, round3(30.0 + 2.6 * g as f64), 340.0], "axis": "Y", "body_name": format!("Cog {g}")}),
);
}
let links = n(112.0);
for k in 0..links {
let a = std::f64::consts::TAU * k as f64 / links as f64;
let (x, z) = (round3(-210.0 + 230.0 * a.cos()), round3(320.0 + 80.0 * a.sin()));
for (side, y) in [("in", 46), ("out", 56)] {
s.c("solid.box", json!({"length": 12, "width": 1, "height": 8, "corner": [round3(x - 6.0), y, round3(z - 4.0)], "body_name": format!("Link {k} {side}")}));
}
s.c("solid.cylinder", json!({"radius": 3.5, "height": 10, "base": [x, 46, z], "axis": "Y", "body_name": format!("Roller {k}")}));
}
for (side, y) in [("L", -90.0), ("R", 90.0)] {
let x0 = if side == "L" { -15.0 } else { 0.0 };
s.c("solid.box", json!({"length": 170, "width": 12, "height": 30, "corner": [x0, y - 6.0, 285], "body_name": format!("Crank {side}")}));
let py = if y > 0.0 { y + 8.0 } else { y - 68.0 };
s.c("solid.box", json!({"length": 90, "width": 60, "height": 15, "corner": [150.0, py, 280], "body_name": format!("Pedal {side}")}));
}
s.root();
// Cockpit and seat.
s.component("Cockpit");
s.tube([600.0, 0.0, 860.0], [620.0, 0.0, 940.0], 14.0, "Steerer");
s.tube([620.0, 0.0, 940.0], [700.0, 0.0, 950.0], 16.0, "Stem");
s.tube([700.0, -380.0, 950.0], [700.0, 380.0, 950.0], 11.0, "Handlebar");
s.c("solid.cylinder", json!({"radius": 16, "height": 120, "base": [700, 260, 950], "axis": "Y", "body_name": "Grip R"}));
s.c("solid.mirror", json!({"bodies": ["Grip R"], "plane": "XZ"}));
s.root();
s.component("Seat");
s.tube([-150.0, 0.0, 820.0], [-170.0, 0.0, 960.0], 13.0, "Seatpost");
s.c("solid.box", json!({"length": 260, "width": 140, "height": 40, "center": [-180, 0, 980], "body_name": "Saddle"}));
s.root();
// Fasteners: head, shank and washer, each its own body.
s.component("Hardware");
let bolts = n(240.0);
for k in 0..bolts {
let a = std::f64::consts::TAU * k as f64 / bolts as f64;
let p = [round3(-300.0 + 2.5 * k as f64), round3(120.0 + 10.0 * a.sin()), round3(400.0 + 10.0 * a.cos())];
s.c("solid.cylinder", json!({"radius": 5, "height": 4, "base": p, "axis": "Z", "body_name": format!("Bolt {k} head")}));
s.c(
"solid.cylinder",
json!({"radius": 2.5, "height": 16, "base": [p[0], p[1], round3(p[2] - 16.0)], "axis": "Z", "body_name": format!("Bolt {k} shank")}),
);
s.c(
"solid.cylinder",
json!({"radius": 4.5, "height": 1, "base": [p[0], p[1], round3(p[2] - 1.0)], "axis": "Z", "body_name": format!("Bolt {k} washer")}),
);
}
s.root();
json!({"commands": s.commands})
}
pub fn run(args: &[String]) -> Result<(), String> {
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1));
let scale = match flag("--scale") {
Some(v) => v.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0 && *v <= 20.0).ok_or("--scale takes a number from 0 to 20")?,
None => 1.0,
};
let script = bicycle(scale);
if let Some(path) = flag("--script-out") {
let text = serde_json::to_string_pretty(&script).map_err(|e| e.to_string())?;
return solidcraft_engine::io::vfs::write(path, text.as_bytes()).map_err(|e| format!("{path}: {e}"));
}
let commands = script["commands"].as_array().cloned().unwrap_or_default();
let mut s = Session::default();
let began = std::time::Instant::now();
// Per command: the command itself, then what a window needs to draw its result (the
// bodies placed in the world and their display meshes).
let (mut run, mut shown): (Vec<f64>, Vec<f64>) = (Vec::new(), Vec::new());
for (i, c) in commands.iter().enumerate() {
let id = c["command"].as_str().unwrap_or_default();
let t = std::time::Instant::now();
s.execute(id, &c["params"]).map_err(|e| format!("command {i} ({id}): {e}"))?;
run.push(t.elapsed().as_secs_f64() * 1000.0);
let t = std::time::Instant::now();
s.world_state().warm_meshes(|| true);
shown.push(t.elapsed().as_secs_f64() * 1000.0);
if run.len() == 100 || i + 1 == commands.len() {
let mean = |v: &[f64]| v.iter().sum::<f64>() / v.len().max(1) as f64;
let bodies = s.model.state().bodies.len();
println!(
"{}",
json!({"step": "build", "commands": i + 1, "bodies": bodies, "command_ms": mean(&run), "display_ms": mean(&shown),
"max_ms": run.iter().zip(&shown).map(|(a, b)| a + b).fold(0.0, f64::max)})
);
run.clear();
shown.clear();
}
}
let world = s.world_state();
println!(
"{}",
json!({"step": "built", "ms": began.elapsed().as_secs_f64() * 1000.0, "commands": commands.len(), "features": s.doc.features.len(), "bodies": world.bodies.len()})
);
let path = match flag("--save") {
Some(p) => p.clone(),
None => std::env::temp_dir().join(format!("bench-agent-{}.solidcraft", std::process::id())).to_string_lossy().into_owned(),
};
s.execute("file.save", &json!({"path": path})).map_err(|e| e.to_string())?;
let r = crate::cmd_bench_open(std::slice::from_ref(&path));
if flag("--save").is_none() {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(format!("{path}.bak"));
}
r
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated commands stay valid as the command set changes: a small bicycle builds
/// without a failing command or feature.
#[test]
fn a_small_bicycle_builds() {
let script = bicycle(0.03);
let mut s = Session::default();
for c in script["commands"].as_array().into_iter().flatten() {
let id = c["command"].as_str().unwrap_or_default();
if let Err(e) = s.execute(id, &c["params"]) {
panic!("{id}: {e}");
}
}
assert!(s.model.results.iter().all(|r| r.error.is_none()));
let world = s.world_state();
assert!(world.bodies.len() > 40, "{} bodies", world.bodies.len());
assert!(world.bodies.iter().any(|b| b.name.contains("spoke")));
}
}
-103
View File
@@ -1,103 +0,0 @@
//! `solidcraft-cli crashes`: the crash reports the desktop app wrote on this computer
//! (docs/crash-reports.md): list them, show one, replay one, delete them.
use std::sync::Mutex;
use serde_json::{Value, json};
use solidcraft_engine::crash;
const USAGE: &str = "usage: solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]";
pub fn run(args: &[String]) -> Result<(), String> {
let dir = crash::default_dir().ok_or("no crash folder on this system")?;
let id = || args.get(1).map(String::as_str).ok_or_else(|| USAGE.to_string());
match args.first().map(String::as_str) {
None | Some("list") => {
let reports = crash::list(&dir);
if args.iter().any(|a| a == "--json") {
println!("{}", super::pretty(&json!({ "dir": dir.to_string_lossy(), "reports": reports })));
return Ok(());
}
println!("crash reports in {}", dir.display());
if reports.is_empty() {
println!(" (none)");
}
for r in reports {
let times = if r.count > 1 { format!(" ×{}", r.count) } else { String::new() };
let cmd = r.command.map(|c| format!(" in {c}")).unwrap_or_default();
let replay = if r.replayable { " [replayable]" } else { "" };
println!(" {} {}{times}{cmd} v{}{replay}\n {}", r.id, r.kind, r.version, r.message);
}
Ok(())
}
Some("show") => {
println!("{}", super::pretty(&crash::read(&dir, id()?)?));
Ok(())
}
Some("replay") => replay(&crash::read(&dir, id()?)?),
Some("dir") => {
println!("{}", dir.display());
Ok(())
}
Some("clear") => {
println!("deleted {} report(s)", crash::clear(&dir, args.get(1).map(String::as_str)));
Ok(())
}
_ => Err(USAGE.to_string()),
}
}
/// Panics seen while replaying: (location, message).
static PANICS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
/// Run a report's commands on its design and say whether the crash happens again.
fn replay(report: &Value) -> Result<(), String> {
let id = report["id"].as_str().unwrap_or_default();
let replay_path = report["replay_path"].as_str().ok_or_else(|| format!("report {id} cannot be replayed (see its notes)"))?;
let script = super::read_json(replay_path)?;
let commands = script["commands"].as_array().cloned().unwrap_or_default();
let mut s = match report["design_path"].as_str() {
Some(d) => super::load(d)?,
None => solidcraft_engine::Session::default(),
};
std::panic::set_hook(Box::new(|info| {
let loc = info.location().map_or_else(|| "(unknown)".to_string(), |l| format!("{}:{}", l.file(), l.line()));
let msg = info.payload_as_str().unwrap_or("(not a text message)").to_string();
if let Ok(mut p) = PANICS.lock() {
p.push((loc, msg));
}
}));
if report["kind"] == "unclean_exit" {
println!("this crash ended the app without a report: if this process ends abruptly, it reproduced");
}
println!(
"replaying {} command(s) on {}",
commands.len(),
report["design_path"].as_str().map_or_else(|| "a new design".to_string(), |d| d.to_string())
);
for (i, c) in commands.iter().enumerate() {
let cmd = c["command"].as_str().unwrap_or_default();
let r = s.execute(cmd, c.get("params").unwrap_or(&Value::Null));
match r {
Ok(_) => println!(" {:>3}. {cmd}: ok", i + 1),
Err(e) => println!(" {:>3}. {cmd}: {e}", i + 1),
}
}
let _ = std::panic::take_hook();
let seen = PANICS.lock().map(|p| p.clone()).unwrap_or_default();
let want = report["signature"].as_str().unwrap_or_default();
let same = seen.iter().any(|(l, m)| format!("{:016x}", crash::signature(l, m)) == want);
if same {
println!(
"reproduced: the same internal error ({}) at {}",
report["message"].as_str().unwrap_or_default(),
report["location"].as_str().unwrap_or_default()
);
} else if let Some((l, m)) = seen.first() {
println!("an internal error happened, but not the reported one: {m} at {l}");
println!("(the report says: {} at {})", report["message"].as_str().unwrap_or_default(), report["location"].as_str().unwrap_or_default());
} else {
println!("did not reproduce: no internal error (fixed, or it depends on something the replay does not have)");
}
Ok(())
}
-593
View File
@@ -1,593 +0,0 @@
//! SolidCraft headless command line.
//!
//! ```text
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect) measurements as JSON
//! solidcraft-cli script <design.solidcraft> [--out script.json] the design's history as a command script
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]] loading timings as JSON lines
//! solidcraft-cli bench-agent [--scale F] [--save F] [--script-out F] a ~1,300-body design built command by command, timed
//! solidcraft-cli snapshot <script|design> [--in design|part.step] --out shot.png [--width W] [--height H] [--view iso|front|top|…|NAME | --camera JSON] [--no-grid] [--no-axes]
//! solidcraft-cli exec <command> [json-params] run one command on an empty design
//! solidcraft-cli commands the command registry as JSON
//! solidcraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solidcraft-cli oracle <case-dir>... replay recipes and compare with expected.json
//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
//! MCP server on stdio (docs/mcp.md)
//! solidcraft-cli licences SolidCraft's licence and the third-party licences
//! solidcraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bench_agent;
mod crashes;
mod fuzz;
mod oracle;
mod recipe;
mod render;
mod spatial;
use std::process::ExitCode;
use serde_json::{Value, json};
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
use solidcraft_engine::Session;
use solidcraft_engine::render::render_png;
use solidcraft_engine::view::Guides;
const USAGE: &str = "usage:
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--json-out FILE] [--allow-errors]
solidcraft-cli run --in <design.solidcraft|part.step> [--out FILE]...
(a timeline feature that fails fails the run and nothing is written; --allow-errors writes anyway)
solidcraft-cli eval <script.json|design.solidcraft|part.step> [--json-out FILE] (alias: inspect; or --in FILE)
(--json-out FILE writes the JSON that run (with --results, else the measurement) and eval print to FILE instead of stdout,
as plain UTF-8: a shell's > can re-encode it, e.g. Windows PowerShell adds a byte-order mark)
solidcraft-cli script <design.solidcraft|script.json> [--out script.json]
(the design's history as a command script that eval and run replay)
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] [--display LEVEL [--edit NAME EXPR]]
solidcraft-cli bench-agent [--scale F] [--save design.solidcraft] [--script-out script.json]
solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]
solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...
(as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;
--view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;
--camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);
--no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture)
solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]
solidcraft-cli exec <command> [json-params]
solidcraft-cli commands
solidcraft-cli recipe <recipe.json>
solidcraft-cli oracle <case-dir>... [--json]
solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path]
solidcraft-cli crashes [list [--json] | show <id> | replay <id> | dir | clear [<id>]]
solidcraft-cli licences
solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
Some("script") => cmd_script(&args[1..]),
Some("snapshot") => cmd_snapshot(&args[1..]),
Some("render") => render::run(&args[1..]),
Some("exec") => cmd_exec(&args[1..]),
Some("commands") => {
use std::io::Write as _;
let mut s = Session::default();
s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()).and_then(|v| {
// A pipeline consumer may stop early (e.g. `commands | head`).
// Unlike println!, writing explicitly lets us handle that without a panic.
match writeln!(std::io::stdout().lock(), "{}", pretty(&v)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(format!("stdout: {e}")),
}
})
}
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
let v = read_json(p)?;
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
}),
Some("oracle") => oracle::run(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("crashes") => crashes::run(&args[1..]),
Some("icon") => cmd_icon(&args[1..]),
Some("licences" | "licenses") => {
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(solidcraft_engine::licences::all().as_bytes());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("bench-open") => cmd_bench_open(&args[1..]),
// A ~1,300-body design built command by command, as an agent builds one over MCP.
Some("bench-agent") => bench_agent::run(&args[1..]),
Some("--version" | "-V") => {
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
_ => Err(USAGE.to_string()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
pub fn read_json(path: &str) -> Result<Value, String> {
// Through the engine's file access, which refuses anything that is not a regular file and
// reads no more than the cap: a character device or a FIFO would otherwise never end.
let s = solidcraft_engine::io::vfs::read_to_string(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
let mut s = Session::default();
if solidcraft_engine::io::is_step_path(path) || solidcraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
}
let bytes = solidcraft_engine::io::vfs::read(path, solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
if solidcraft_engine::io::is_design_container(&bytes) {
let doc = solidcraft_engine::io::read_design(&bytes).map_err(|e| format!("{path}: {e}"))?;
let mut session = Session::new(doc);
session.path = Some(path.to_owned());
return Ok(session);
}
let v: Value = serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))?;
if v.get("format").and_then(Value::as_str).is_some_and(|f| solidcraft_engine::doc::format::version_of(f).is_some()) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else if v.get("features").is_some() && v.get("commands").is_none() {
let script = recipe::to_script(&v)?;
s.run_script(&script).map_err(|e| e.to_string())?;
} else {
s.run_script(&v).map_err(|e| e.to_string())?;
}
Ok(s)
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> {
args.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
}
/// The first argument that is not an option or an option's value.
fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors" | "--no-grid" | "--no-axes") { 1 } else { 2 };
continue;
}
return Some(a);
}
None
}
/// `--json-out FILE`: where the JSON a command prints goes instead of stdout.
fn json_out(args: &[String]) -> Result<Option<&str>, String> {
if !args.iter().any(|a| a == "--json-out") {
return Ok(None);
}
match flag(args, "--json-out") {
Some(file) if !file.starts_with("--") => Ok(Some(file)),
_ => Err("--json-out needs a FILE".to_string()),
}
}
/// `v` as indented JSON with a final newline: on stdout, or in `file`. The file is plain UTF-8
/// with no byte-order mark, whatever the platform's shell does to redirected output (Windows
/// PowerShell's `>` writes UTF-16, or UTF-8 with a BOM), so strict JSON readers take it.
fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> {
match file {
Some(file) => {
let mut text = pretty(v);
text.push('\n');
std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
}
None => {
println!("{}", pretty(v));
Ok(())
}
}
}
fn cmd_run(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
}
let (mut s, results) = if args.iter().any(|a| a == "--results") {
// Collect every command's result (scripts only), then process requested files too.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
(s, Some(Value::Array(r)))
} else {
(open(base, path)?, None)
};
// A feature that fails leaves the bodies as they were before it (a parameter change can
// break a feature without failing the command that made it): writing them would hand out
// the wrong part with success, so fail the run unless asked to write it anyway.
if !args.iter().any(|a| a == "--allow-errors") {
timeline_ok(&s)?;
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if let Some(results) = results {
print_json(&results, json_out)?;
} else if json_out.is_some() || !args.iter().any(|a| a == "--quiet") {
// `--quiet` silences stdout; a file that was asked for is still written.
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
print_json(&m, json_out)?;
}
Ok(())
}
/// The session `run` and `snapshot` start from: `--in` (a design or STEP file) with the
/// positional script run on top of it, or either one alone.
fn open(base: Option<&str>, path: Option<&String>) -> Result<Session, String> {
match (base, path) {
(Some(b), Some(p)) => {
let mut s = load(b)?;
s.run_script(&read_json(p)?).map_err(|e| e.to_string())?;
Ok(s)
}
(Some(b), None) => load(b),
(None, Some(p)) => load(p),
(None, None) => Err(USAGE.to_string()),
}
}
/// Refuses an option `cmd` doesn't take, and one missing its value: a typo, or another
/// command's option, would otherwise be ignored without a word.
fn known_flags(cmd: &str, args: &[String], valued: &[&str], switches: &[&str]) -> Result<(), String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if valued.contains(&a.as_str()) {
match args.get(i + 1) {
Some(v) if !v.starts_with("--") => i += 2,
_ => return Err(format!("{cmd}: {a} needs a value")),
}
} else if a.starts_with("--") && !switches.contains(&a.as_str()) {
return Err(format!("{cmd} doesn't take {a} (it takes {})", [valued, switches].concat().join(", ")));
} else {
i += 1;
}
}
Ok(())
}
/// Every failing timeline feature, one per line, as an error; Ok when none fails.
fn timeline_ok(s: &Session) -> Result<(), String> {
let failed: Vec<String> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!(" {}: {e}", r.name))).collect();
if failed.is_empty() {
return Ok(());
}
Err(format!(
"{} timeline feature(s) failed, so nothing was written (--allow-errors writes the bodies anyway):\n{}",
failed.len(),
failed.join("\n")
))
}
fn cmd_eval(args: &[String]) -> Result<(), String> {
let json_out = json_out(args)?;
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
"timeline": d["timeline"],
"params": d["params"],
"sketches": d["sketches"],
});
print_json(&out, json_out)
}
/// A design's history as a command script (`timeline.export_script`): printed, or written to
/// `--out`; what the script leaves out goes to stderr.
fn cmd_script(args: &[String]) -> Result<(), String> {
let path = positional(args).ok_or_else(|| USAGE.to_string())?;
let s = &mut load(path)?;
let out = flag(args, "--out");
let r = s.execute("timeline.export_script", &json!({"path": out})).map_err(|e| e.to_string())?;
for what in r["omitted"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("note: the script leaves out {what}");
}
match out {
Some(out) => eprintln!("{out}: {} steps", r["steps"]),
None => println!("{}", pretty(&r["script"])),
}
Ok(())
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?;
if positional(args).is_none() && flag(args, "--in").is_none() {
return Err(USAGE.to_string());
}
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let size = |name: &str, default: usize| match flag(args, name) {
None => Ok(default),
Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")),
};
let (w, h) = (size("--width", 1280)?, size("--height", 800)?);
let mut s = open(flag(args, "--in"), positional(args))?;
// The camera choices of the MCP `screenshot`, through the same command (`view.apply`): a
// standard view, a view saved in the design, or an explicit camera. A script that applies a
// view itself sets the camera too; without any, the iso view framing the model.
match (flag(args, "--view"), flag(args, "--camera")) {
(Some(_), Some(_)) => return Err("snapshot: give --view or --camera, not both".into()),
(Some(v), None) => {
let hint = "standard views: iso, front, back, top, bottom, left, right";
s.execute("view.apply", &json!({"view": v})).map_err(|e| format!("--view {v}: {e} ({hint}; or a view saved with view.save)"))?;
}
(None, Some(c)) => {
let camera: Value = serde_json::from_str(c).map_err(|e| format!("--camera: not JSON: {e}"))?;
s.execute("view.apply", &json!({"camera": camera})).map_err(|e| format!("--camera: {e}"))?;
}
(None, None) => {}
}
let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s));
let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") };
let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
Ok(())
}
/// The app icon: the SolidCraft logo, drawn in code on a transparent background
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
fn cmd_icon(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
let pngs: Vec<(usize, Vec<u8>)> =
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
ico(&pngs)?
} else {
icon_png(size)?
};
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
Ok(())
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
solidcraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
let mut out = Vec::new();
out.extend_from_slice(&[0, 0, 1, 0]);
out.extend_from_slice(&n.to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in images {
let dim = if *size >= 256 { 0 } else { *size as u8 };
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
offset += data.len();
}
for (_, data) in images {
out.extend_from_slice(data);
}
Ok(out)
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let p: Value = match args.get(1) {
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
None => json!({}),
};
let mut s = Session::default();
let v = s.execute(id, &p).map_err(|e| e.to_string())?;
println!("{}", pretty(&v));
Ok(())
}
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
/// a running app's control port. stdout carries only protocol messages. Files the agent's tool
/// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given.
fn cmd_mcp(args: &[String]) -> Result<(), String> {
let scope = (!args.iter().any(|a| a == "--allow-any-path"))
.then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from)));
if let Some(s) = &scope {
let roots: Vec<String> = s.roots().iter().map(|r| r.display().to_string()).collect();
eprintln!(
"solidcraft mcp: files limited to {} and the open design's folder (--allow-path DIR adds one, --allow-any-path lifts the limit)",
roots.join(", ")
);
let design_dir = flag(args, "--in")
.and_then(|p| std::path::Path::new(p).parent())
.map(|d| if d.as_os_str().is_empty() { std::path::Path::new(".") } else { d });
if let Some(why) = design_dir.and_then(solidcraft_engine::io::vfs::design_folder_refusal) {
eprintln!("solidcraft mcp: the design's folder is left out because {why}; add it with --allow-path if you mean it");
}
}
let backend: Box<dyn solidcraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => {
let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok()).filter(|p| *p != 0) else {
return Err(format!("mcp: --connect wants HOST:PORT, such as 127.0.0.1:7878, not `{addr}`"));
};
// Serve without the app too: the tools connect once it runs, and say it isn't until then.
let mut remote = solidcraft_mcp::Remote::new(addr, flag(args, "--token"));
if let Err(e) = remote.try_connect() {
eprintln!(
"solidcraft mcp: no SolidCraft app at {addr} yet ({e}); start it with `solidcraft --control {port}` \
(or SOLIDCRAFT_CONTROL_PORT={port}), tool calls connect once it runs"
);
}
Box::new(remote)
}
(None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solidcraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
/// parameter `save_round` set to the round number each time.
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solidcraft_engine::doc::Param {
name: format!("p{i}"),
expr: format!("{i} mm + 0.5 mm"),
unit: "mm".into(),
comment: "filler to make the file large".into(),
model: false,
});
s.doc_mut().params.extend(filler);
for r in 0..rounds {
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
}
Ok(())
}
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
return Err("usage: solidcraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
};
let v = read_json(path)?;
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
let mut s = Session::default();
let t = std::time::Instant::now();
s.run_script(&script).map_err(|e| e.to_string())?;
let build = t.elapsed().as_secs_f64() * 1000.0;
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
let t = std::time::Instant::now();
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
Ok(())
};
let expr = expr.replace("{old}", &old);
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "edit.undo", json!({}))?;
time(&mut s, "redo", "edit.redo", json!({}))?;
println!("{}", pretty(&json!(out)));
Ok(())
}
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
pub(crate) fn cmd_bench_open(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("usage: solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]")?;
let t = std::time::Instant::now();
let step = solidcraft_engine::io::is_step_path(path);
let limit = if step { solidcraft_engine::io::MAX_STEP_BYTES } else { solidcraft_engine::io::MAX_DESIGN_BYTES };
let bytes = solidcraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
let read_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()}));
let t = std::time::Instant::now();
// The file as read, held until the timeline has evaluated it (as the import command does).
let mut read = None;
let doc = if step {
let imported = solidcraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
let mut doc = solidcraft_engine::doc::Document::new(&imported.name);
doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?;
read = Some(imported.import);
doc
} else {
solidcraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
};
let parse_ms = t.elapsed().as_secs_f64() * 1000.0;
println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()}));
if args.iter().any(|a| a == "--parse-only") {
return Ok(());
}
let t = std::time::Instant::now();
let s = Session::new(doc);
drop(read);
println!("{}", json!({"step": "evaluate", "ms": t.elapsed().as_secs_f64() * 1000.0}));
for r in &s.model.results {
println!("{}", json!({"step": "feature", "id": r.id, "name": r.name, "ms": r.ms, "error": r.error}));
}
let t = std::time::Instant::now();
let st = s.world_state();
// As the desktop app prepares a design it opened.
st.warm_meshes(|| true);
let triangles: usize = st.bodies.iter().map(|b| b.mesh().triangles.len()).sum();
println!("{}", json!({"step": "mesh", "ms": t.elapsed().as_secs_f64() * 1000.0, "bodies": st.bodies.len(), "triangles": triangles}));
// `--display LEVEL`: the display plans the viewport draws at that display quality, made as
// the GPU viewport makes them; `--edit NAME EXPR` then changes a parameter and times the
// evaluation and the plans of the bodies it changed.
let flag = |name: &str| args.iter().position(|a| a == name);
let Some(level) = flag("--display").and_then(|i| args.get(i + 1)) else { return Ok(()) };
let q = solidcraft_engine::doc::display_quality::DisplayQuality::parse(level).ok_or("--display: coarse, normal, fine or ultra")?;
let plans = |st: &solidcraft_engine::doc::ModelState| {
let t = std::time::Instant::now();
solidcraft_engine::kernel::for_each_on_threads(&st.bodies, 16, |b| {
let _ = b.plan_mesh_at(q);
});
let triangles: usize = st.bodies.iter().filter_map(|b| b.plan_mesh_at(q)).map(|m| m.triangles.len()).sum();
(t.elapsed().as_secs_f64() * 1000.0, triangles)
};
let (ms, triangles) = plans(&st);
println!("{}", json!({"step": "display", "quality": q.id(), "ms": ms, "triangles": triangles}));
if let Some(i) = flag("--edit") {
let (Some(name), Some(expr)) = (args.get(i + 1), args.get(i + 2)) else { return Err("--edit NAME EXPR".into()) };
let mut s = s;
let t = std::time::Instant::now();
s.execute("parameters.change", &json!({"name": name, "expression": expr})).map_err(|e| e.to_string())?;
let eval_ms = t.elapsed().as_secs_f64() * 1000.0;
let st = s.world_state();
let t = std::time::Instant::now();
st.warm_meshes(|| true);
let mesh_ms = t.elapsed().as_secs_f64() * 1000.0;
let (ms, triangles) = plans(&st);
println!(
"{}",
json!({"step": "edit", "quality": q.id(), "evaluate_ms": eval_ms, "mesh_ms": mesh_ms, "display_ms": ms, "triangles": triangles})
);
}
Ok(())
}
-263
View File
@@ -1,263 +0,0 @@
//! Reference tests: replay `tests/oracle/<case>/recipe.json` through the command engine and
//! compare the part with `expected.json` (closed-form values, or values from an independent
//! kernel; see tests/oracle/README.md), and with `reference.stl` where one exists.
#![allow(clippy::needless_range_loop)]
use serde_json::{Value, json};
use solidcraft_engine::geom::Vec3;
use crate::spatial::{self, Tri};
use crate::{read_json, recipe};
/// Default relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
pub const REL_TOL: f64 = 1e-3;
/// Default tolerance for centre of mass and bounding box, as a fraction of the bbox diagonal.
const ABS_FRAC: f64 = 1e-3;
/// Chord tolerance used to triangulate our part for the surface-distance check (mm).
const MESH_TOL: f64 = 0.01;
/// Relative tolerance on the principal second moments of the two triangulations.
const INERTIA_REL: f64 = 2e-3;
/// What SolidCraft built: totals over every body.
struct Got {
body_count: usize,
volume: f64,
area: f64,
com: [f64; 3],
bbox: ([f64; 3], [f64; 3]),
faces: u64,
}
fn v3(v: &Value) -> Option<[f64; 3]> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn totals(m: &Value) -> Got {
let bodies = m["bodies"].as_array().cloned().unwrap_or_default();
let (mut volume, mut area, mut faces) = (0.0, 0.0, 0);
let mut moment = [0.0; 3];
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for b in &bodies {
let v = b["volume_mm3"].as_f64().unwrap_or(f64::NAN);
volume += v;
area += b["area_mm2"].as_f64().unwrap_or(f64::NAN);
faces += b["faces"].as_u64().unwrap_or(0);
let c = v3(&b["center_of_mass"]).unwrap_or([f64::NAN; 3]);
let (bl, bh) = (v3(&b["bbox"]["min"]).unwrap_or([f64::NAN; 3]), v3(&b["bbox"]["max"]).unwrap_or([f64::NAN; 3]));
for k in 0..3 {
moment[k] += v * c[k];
lo[k] = lo[k].min(bl[k]);
hi[k] = hi[k].max(bh[k]);
}
}
let com = moment.map(|x| x / volume);
Got { body_count: bodies.len(), volume, area, com, bbox: (lo, hi), faces }
}
struct Checks {
list: Vec<Value>,
pass: bool,
}
impl Checks {
fn push(&mut self, what: &str, got: Value, want: Value, ok: bool) {
self.pass &= ok;
self.list.push(json!({"check": what, "got": got, "want": want, "ok": ok}));
}
fn rel(&mut self, what: &str, g: f64, w: Option<f64>, rel: f64) {
let ok = w.is_some_and(|w| g.is_finite() && (g - w).abs() <= rel * w.abs().max(1.0));
self.push(what, json!(g), json!(w), ok);
}
fn vec(&mut self, what: &str, g: [f64; 3], w: Option<[f64; 3]>, tol: f64) {
let ok = w.is_some_and(|w| g.iter().zip(w).all(|(g, w)| g.is_finite() && (g - w).abs() <= tol));
self.push(what, json!(g), json!(w), ok);
}
}
fn check_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let recipe = match read_json(&format!("{dir}/recipe.json")) {
Ok(r) => r,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let want = read_json(&format!("{dir}/expected.json")).ok();
let script = match recipe::to_script(&recipe) {
Ok(s) => s,
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
};
let mut s = solidcraft_engine::Session::default();
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string(), "source": want.as_ref().map(|w| w["source"].clone())});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => totals(&m),
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got_json = json!({
"body_count": got.body_count, "volume_mm3": got.volume, "area_mm2": got.area,
"center_of_mass": got.com, "bbox": {"min": got.bbox.0, "max": got.bbox.1}, "faces": got.faces,
});
let Some(want) = want else {
return json!({"case": name, "pass": false, "skipped": true, "error": "no expected.json yet", "got": got_json});
};
let only: Option<Vec<String>> = want["compare"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect());
let wanted = |k: &str| want.get(k).is_some_and(|v| !v.is_null()) && only.as_ref().is_none_or(|o| o.iter().any(|x| x == k));
let rel = want["tolerance"]["rel"].as_f64().unwrap_or(REL_TOL);
let diag = |b: ([f64; 3], [f64; 3])| ((b.1[0] - b.0[0]).powi(2) + (b.1[1] - b.0[1]).powi(2) + (b.1[2] - b.0[2]).powi(2)).sqrt();
let want_bbox = v3(&want["bbox"]["min"]).zip(v3(&want["bbox"]["max"]));
let abs = want["tolerance"]["abs_mm"].as_f64().unwrap_or_else(|| ABS_FRAC * want_bbox.map(diag).unwrap_or_else(|| diag(got.bbox)).max(1.0));
let mut c = Checks { list: Vec::new(), pass: true };
if wanted("body_count") {
let ok = want["body_count"].as_u64() == Some(got.body_count as u64);
c.push("body_count", json!(got.body_count), want["body_count"].clone(), ok);
}
if wanted("volume_mm3") {
c.rel("volume_mm3", got.volume, want["volume_mm3"].as_f64(), rel);
}
if wanted("area_mm2") {
c.rel("area_mm2", got.area, want["area_mm2"].as_f64(), rel);
}
if wanted("center_of_mass") {
c.vec("center_of_mass", got.com, v3(&want["center_of_mass"]), abs);
}
if wanted("bbox") {
c.vec("bbox.min", got.bbox.0, want_bbox.map(|b| b.0), abs);
c.vec("bbox.max", got.bbox.1, want_bbox.map(|b| b.1), abs);
}
if wanted("faces") {
let ok = want["faces"].as_u64() == Some(got.faces);
c.push("faces", json!(got.faces), want["faces"].clone(), ok);
}
let stl = format!("{dir}/reference.stl");
if std::path::Path::new(&stl).is_file() && only.as_ref().is_none_or(|o| o.iter().any(|x| x == "surface")) {
let tol = want["tolerance"]["surface_mm"].as_f64().unwrap_or(0.05);
reference_checks(&s, &stl, tol, &mut c);
}
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
// same volume.
let (ok, note) = step_export_check(&s, got.volume);
c.push("step_export", json!(note), json!("valid, re-imports cleanly"), ok);
let mut out = json!({"case": name, "pass": c.pass, "source": want["source"], "checks": c.list, "got": got_json});
if let Some(n) = want["note"].as_str() {
out["note"] = json!(n);
}
out
}
/// Export the session's bodies as STEP, validate the file and import it again.
fn step_export_check(s: &solidcraft_engine::Session, volume: f64) -> (bool, String) {
let st = s.model.state();
let bytes = match solidcraft_engine::io::export(&st, &[], solidcraft_engine::io::Format::Step, "oracle") {
Ok(b) => b,
Err(e) => return (false, format!("export: {e}")),
};
let text = String::from_utf8_lossy(&bytes);
if let Err(e) = solidcraft_engine::kernel::step_validate(&text) {
return (false, format!("invalid: {e}"));
}
match solidcraft_engine::kernel::step_orientation_errors(&text) {
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
Err(e) => return (false, format!("orientation check: {e}")),
Ok(_) => {}
}
match solidcraft_engine::kernel::step_import(&text) {
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
Ok(imp) => {
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solidcraft_engine::kernel::measure(&b.body).ok()).collect();
let v: f64 = m.iter().map(|x| x.volume).sum();
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
}
Err(e) => (false, format!("import: {e}")),
}
}
/// Our part as a triangle soup (curved faces refined onto the surface).
fn our_mesh(s: &solidcraft_engine::Session) -> Result<Vec<Tri>, String> {
let mut ours: Vec<Tri> = Vec::new();
for b in &s.world_state().bodies {
let m = b.body.tessellate_with(MESH_TOL, true).map_err(|e| e.to_string())?;
let p = |i: u32| m.positions.get(i as usize).map(|v: &Vec3| [v.x, v.y, v.z]);
for t in &m.triangles {
if let (Some(a), Some(b), Some(c)) = (p(t[0]), p(t[1]), p(t[2])) {
ours.push([a, b, c]);
}
}
}
if ours.is_empty() { Err("empty mesh".into()) } else { Ok(ours) }
}
/// Compare our part with the reference triangulation: the symmetric surface distance, and the
/// principal second moments of volume of both meshes (the shape's inertia, independent of how it
/// sits in space).
fn reference_checks(s: &solidcraft_engine::Session, stl: &str, tol: f64, c: &mut Checks) {
let (ours, reference) = match (our_mesh(s), spatial::read_stl(stl)) {
(Ok(o), Ok(r)) if !r.is_empty() => (o, r),
(Err(e), _) | (_, Err(e)) => return c.push("surface_distance_mm", json!(e), json!(format!("<= {tol}")), false),
_ => return c.push("surface_distance_mm", json!("empty reference.stl"), json!(format!("<= {tol}")), false),
};
let (lo, hi) = spatial::bounds(&reference);
let size = (0..3).map(|k| hi[k] - lo[k]).fold(0.0, f64::max);
let h = spatial::hausdorff(&ours, &reference, size.max(1.0));
let worst = h.a_to_b.max(h.b_to_a);
c.push(
"surface_distance_mm",
json!({"max": worst, "mean": h.mean, "ours_to_reference": h.a_to_b, "reference_to_ours": h.b_to_a, "worst_at": h.worst_at}),
json!(format!("<= {tol}")),
worst <= tol,
);
let ((_, _, pa), (_, _, pb)) = (spatial::mass_properties(&ours), spatial::mass_properties(&reference));
let ok = pa.iter().zip(pb).all(|(a, b)| (a - b).abs() <= INERTIA_REL * b.abs().max(1.0));
c.push("principal_moments_mm5", json!(pa), json!(pb), ok);
}
fn report(args: &[String], results: &[Value]) {
if args.iter().any(|a| a == "--json") {
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
return;
}
for r in results {
let status = if r["pass"].as_bool().unwrap_or(false) {
"PASS"
} else if r["skipped"].as_bool().unwrap_or(false) {
"SKIP"
} else {
"FAIL"
};
let detail: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
println!(
"{status} {}{err}{}",
r["case"].as_str().unwrap_or(""),
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
);
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("oracle: give one or more case directories (each with recipe.json and expected.json)".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
}
-79
View File
@@ -1,79 +0,0 @@
//! Complete headless image-job workflow through the same engine commands as the UI/MCP.
use serde_json::json;
use solidcraft_engine::{
doc::render_settings::RenderSetup,
render::job::{CpuHost, JobId, Phase},
};
pub fn run(args: &[String]) -> Result<(), String> {
let input = args.first().filter(|p| !p.starts_with('-')).ok_or("render requires an input design or script")?;
let mut out = None;
let mut setup = None;
let mut width = None;
let mut height = None;
let mut transparent = false;
let mut overwrite = false;
let mut timeout = 120u64;
let mut i = 1;
while let Some(arg) = args.get(i) {
match arg.as_str() {
"--transparent" => transparent = true,
"--overwrite" => overwrite = true,
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
i += 1;
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
match arg.as_str() {
"--out" => out = Some(value.clone()),
"--setup" => setup = Some(value.clone()),
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
}
}
_ => return Err(format!("unknown render option: {arg}")),
}
i += 1;
}
if !(1..=3600).contains(&timeout) {
return Err("render timeout must be 1..3600 seconds".into());
}
let out = out.ok_or("render requires --out FILE.png")?;
let mut session = crate::load(input)?;
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
let mut settings = if let Some(name) = setup {
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
} else {
let mut settings = RenderSetup::default();
if let Some(camera) = session.camera {
settings.camera = camera;
} else {
settings.camera.fit(&solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?.bounds);
}
settings
};
if let Some(width) = width {
settings.width = width;
}
if let Some(height) = height {
settings.height = height;
}
let params = json!({"settings":settings,"transparent_background":transparent});
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
let status = match handle.wait_timeout(std::time::Duration::from_secs(timeout)) {
Ok(status) => status,
Err(e) => {
let _ = session.execute("render.cancel", &json!({"job":id}));
return Err(e.to_string());
}
};
if status.progress.phase != Phase::Completed {
return Err(status.error.map_or_else(|| "render cancelled".into(), |e| e.to_string()));
}
let exported = session.execute("render.export", &json!({"job":id,"path":out,"overwrite":overwrite})).map_err(|e| e.to_string())?;
println!("{}", crate::pretty(&json!({"preflight":preflight,"export":exported})));
Ok(())
}
-333
View File
@@ -1,333 +0,0 @@
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
//! triangle meshes.
use std::collections::HashMap;
/// A triangle soup in mm.
pub type Tri = [[f64; 3]; 3];
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Read a binary (or ASCII) STL file.
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if b.len() >= 84 {
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
if b.len() == 84 + 50 * n {
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
return Ok((0..n)
.map(|i| {
let o = 84 + 50 * i + 12;
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
[v(0), v(1), v(2)]
})
.collect());
}
}
let text = String::from_utf8_lossy(&b);
let mut verts = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if it.next() == Some("vertex") {
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
if c.len() == 3 {
verts.push([c[0], c[1], c[2]]);
}
}
}
if verts.is_empty() || verts.len() % 3 != 0 {
return Err(format!("{path}: not an STL file"));
}
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
}
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
let (mut v6, mut m1) = (0.0, [0.0; 3]);
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
for t in tris {
let d = dot(t[0], cross(t[1], t[2]));
v6 += d;
let s = add(add(t[0], t[1]), t[2]);
m1 = add(m1, scale(s, d / 24.0));
for (i, row) in c.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
*cell += d / 120.0 * vv;
}
}
}
let vol = v6 / 6.0;
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3], [0.0; 3]);
}
let com = scale(m1, 1.0 / vol);
// Central covariance and inertia tensor I = tr(C)·1 − C.
let mut cc = c;
for (i, row) in cc.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell -= vol * com[i] * com[j];
}
}
let tr = cc[0][0] + cc[1][1] + cc[2][2];
let mut inertia = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
}
}
let mut ev = eigen_sym3(inertia);
ev.sort_by(f64::total_cmp);
(vol, com, ev)
}
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
if a[i][j].abs() > big {
(p, q, big) = (i, j, a[i][j].abs());
}
}
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
let mut b = a;
for k in 0..3 {
b[k][p] = cs * a[k][p] - sn * a[k][q];
b[k][q] = sn * a[k][p] + cs * a[k][q];
}
let mut c2 = b;
for k in 0..3 {
c2[p][k] = cs * b[p][k] - sn * b[q][k];
c2[q][k] = sn * b[p][k] + cs * b[q][k];
}
a = c2;
}
[a[0][0], a[1][1], a[2][2]]
}
/// Axis-aligned bounds of a triangle soup.
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
let (a, b, c) = (t[0], t[1], t[2]);
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
let q = if d1 <= 0.0 && d2 <= 0.0 {
a
} else {
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
b
} else {
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
add(a, scale(ab, d1 / (d1 - d3)))
} else {
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
c
} else {
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
add(a, scale(ac, d2 / (d2 - d6)))
} else {
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
} else {
let den = va + vb + vc;
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
}
}
}
}
}
};
let d = sub(p, q);
dot(d, d).sqrt()
}
/// Uniform grid of triangles for distance queries up to `cap`.
struct Grid<'a> {
tris: &'a [Tri],
cell: f64,
cells: HashMap<(i64, i64, i64), Vec<u32>>,
}
impl<'a> Grid<'a> {
fn new(tris: &'a [Tri], cell: f64) -> Self {
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let key = |x: f64| (x / cell).floor() as i64;
for (i, t) in tris.iter().enumerate() {
let (lo, hi) = bounds(std::slice::from_ref(t));
for x in key(lo[0])..=key(hi[0]) {
for y in key(lo[1])..=key(hi[1]) {
for z in key(lo[2])..=key(hi[2]) {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Grid { tris, cell, cells }
}
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
let rmax = (cap / self.cell).ceil() as i64;
let k = |x: f64| (x / self.cell).floor() as i64;
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
let mut best = cap;
for r in 0..=rmax {
for x in cx - r..=cx + r {
for y in cy - r..=cy + r {
for z in cz - r..=cz + r {
// only the shell at Chebyshev distance r
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
continue;
}
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
for &i in list {
let Some(slot) = seen.get_mut(i as usize) else { continue };
if *slot == stamp {
continue;
}
*slot = stamp;
if let Some(t) = self.tris.get(i as usize) {
best = best.min(point_tri(p, t));
}
}
}
}
}
// Everything not yet searched is at least r·cell away from p.
if best <= r as f64 * self.cell {
break;
}
}
best
}
}
/// At most this many sample points per side (larger meshes are sampled with an even stride).
const MAX_SAMPLES: usize = 250_000;
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
/// very large meshes).
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
for t in tris.iter().step_by(stride) {
out.extend_from_slice(t);
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
}
out
}
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
/// capped at `cap`.
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
let (lo, hi) = bounds(to);
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
// finer than diag/400 (so big flat triangles don't fill too many cells).
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
let typical = (area / to.len().max(1) as f64).sqrt();
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
let mut seen = vec![0u32; to.len()];
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
for (i, p) in samples(from).into_iter().enumerate() {
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
sum += d;
n += 1;
if d > max {
max = d;
worst = p;
}
}
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
}
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
pub struct Hausdorff {
pub a_to_b: f64,
pub b_to_a: f64,
pub mean: f64,
/// Where the larger one-sided distance was found (mm, world).
pub worst_at: [f64; 3],
}
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
let mut t = Vec::new();
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
t
}
#[test]
fn box_moments_and_distance() {
let c = cube(10.0, [5.0, -3.0, 2.0]);
let (v, com, pm) = mass_properties(&c);
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
for m in pm {
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
}
let moved = cube(10.0, [5.0, -3.0, 2.3]);
let h = hausdorff(&c, &moved, 1.0);
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
let same = hausdorff(&c, &c, 1.0);
assert!(same.a_to_b < 1e-12);
}
}
File diff suppressed because it is too large. Load diff
@@ -1,26 +0,0 @@
//! The registry remains valid JSON and a closed pipeline reader must not cause a panic.
use std::process::{Command, Stdio};
#[test]
fn registry_output_is_complete_json() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let registry: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let commands = registry.as_array().unwrap();
assert!(commands.iter().any(|command| command["id"] == "solid.box"));
assert!(commands.iter().any(|command| command["id"] == "engine.commands"));
assert!(output.stdout.ends_with(b"\n"));
assert!(output.stderr.is_empty());
}
#[test]
fn closed_registry_pipe_exits_without_panicking() {
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
// Close our read end before waiting. No sleeps or timing threshold are needed:
// the registry is larger than the pipe buffer, so it cannot all be written first.
drop(child.stdout.take());
let output = child.wait_with_output().unwrap();
assert!(output.status.success(), "status: {}; stderr: {}", output.status, String::from_utf8_lossy(&output.stderr));
assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr));
}
@@ -1,28 +0,0 @@
# Design file corpus
Designs saved by older SolidCraft builds. `tests/design_corpus.rs` opens each one with the
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
it is `solidcraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
world bounding boxes), that it is upgraded to the current file format, and that damaged and
hostile variants of it never panic.
| File | Saved by | Era |
|---|---|---|
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
| `naming-split-pieces` | 9a979cf | format 13 names: a slot splits the top; fillet, chamfer and offset face name its pieces by number (`F2:top#2`) |
| `move-bodies` | 00baf1d | `solid.move` before it could copy: two bodies, one translated in place, one translated and turned 90 deg about Z (no `copy` key anywhere in the file) |
| `split-body-pieces` | v0.3.2 (07e263d) | a slot along X cuts a block into two bodies; a fillet, a chamfer and an appearance refer to the pieces by body name (`Body1`, `Body1 (1)`) |
| `issue-489-stacked-join` | v0.6.0 (809ecf7), desktop | a 70 × 84 extrude, then a 70 × 62 strip joined on its top face, sketched partly over the face's projected edges (issue #489; expected values from main at 2880525) |
| `issue-616-lid-ribs` | v0.7.0 (a9879fa), CLI | a rounded lid with a skirt ring, a window and a slot cut through, then four ribs joined on the plate over the skirt, flush with the window's sides; v0.7.0 displaced the ribs and left a slit through the skirt and plate (issue #616; script `examples/regressions/lid_ribs_flush_with_window.json`, expected values from the fix) |
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
app and has none; `issue-616-lid-ribs` is made by its regression script). Every flat face of every final body must also take an attached sketch and an
offset plane; the faces that can't yet are listed with their issues in `design_corpus.rs`.
Never regenerate a file with a newer build: the point is that it was written by the old one. Add new files for new eras instead.
@@ -1,157 +0,0 @@
{
"bodies": [
{
"area_mm2": 25751.999999999945,
"bbox": {
"max": [
35.0,
42.0,
48.00000000000001
],
"min": [
-35.0,
-42.000000000000014,
0.0
]
},
"center_of_mass": [
5.524909928104648e-14,
-1.0945382096961234e-12,
22.366336633663238
],
"density_g_cm3": 1.29,
"edges": 24,
"face_types": {
"plane": 10
},
"faces": 10,
"kernel": {
"edges": 24,
"faces": 10,
"shells": 1,
"vertices": 16
},
"mass_g": 328.33079999999734,
"material": "Default",
"name": "Body1",
"vertices": 16,
"volume_mm3": 254519.9999999979
}
],
"body_count": 1,
"params": [
{
"comment": "",
"error": null,
"expression": "84",
"model": true,
"name": "d1",
"unit": "mm",
"value": 84.0
},
{
"comment": "",
"error": null,
"expression": "70",
"model": true,
"name": "d2",
"unit": "mm",
"value": 70.0
},
{
"comment": "",
"error": null,
"expression": "62",
"model": true,
"name": "d4",
"unit": "mm",
"value": 62.0
}
],
"sketches": [
{
"curves": 6,
"dof": 0,
"id": 1,
"name": "Sketch1",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 10,
"dof": 1,
"id": 3,
"name": "Sketch2",
"profiles": 3,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.131943,
"name": "Sketch1",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 1.0812709999999999,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.450002,
"name": "Sketch2",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 5.191514000000001,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
}
],
"total": {
"area_mm2": 25751.999999999945,
"edges": 24,
"faces": 10,
"mass_g": 328.33079999999734,
"vertices": 16,
"volume_mm3": 254519.9999999979
}
}
@@ -1,539 +0,0 @@
{
"features": [
{
"id": 1,
"name": "Sketch1",
"plane": {
"name": "XY",
"type": "origin"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"p": 5,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 5,
"type": "coincident"
},
{
"id": "k7",
"l": 1,
"param": "d1",
"type": "length",
"value": 84.0
},
{
"id": "k8",
"l": 0,
"param": "d2",
"type": "length",
"value": 70.0
}
],
"counters": {
"k": 8,
"l": 6,
"p": 5
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 1,
"b": 3,
"construction": true,
"id": "l5",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": true,
"id": "l6",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": false,
"id": "p2",
"pos": [
35.0,
-42.0
]
},
{
"fixed": false,
"id": "p3",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p4",
"pos": [
-35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "30"
},
"id": 2,
"name": "Extrude1",
"operation": "new_body",
"param_names": [
"d3"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 1,
"suppressed": false,
"type": "extrude"
},
{
"id": 3,
"name": "Sketch2",
"plane": {
"anchor": {
"author_path": [],
"component": 0,
"coordinates": [
0.0,
0.0
],
"path": [],
"target": {
"body": "Body1",
"certificate": {
"high": true,
"holes": [],
"kind": "extrude_cap",
"outer": [
"l1",
"l2",
"l3",
"l4"
],
"sketch": 1
},
"name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"origin_feature": 2,
"point": [
0.0,
0.0,
30.0
]
},
"x_axis": [
1.0,
0.0
]
},
"at": [
2.012802880253332,
7.4635742926698185,
30.000000000000114
],
"name": "F2:top",
"plane": {
"origin": [
0.0,
0.0,
30.0
],
"x_dir": [
1.0,
0.0,
0.0
],
"y_dir": [
0.0,
1.0,
0.0
]
},
"type": "face"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 4,
"type": "horizontal"
},
{
"id": "k2",
"l": 6,
"type": "horizontal"
},
{
"id": "k3",
"l": 5,
"type": "vertical"
},
{
"id": "k4",
"l": 7,
"type": "vertical"
},
{
"id": "k5",
"l": 8,
"p": 9,
"type": "midpoint"
},
{
"id": "k6",
"p": 0,
"q": 9,
"type": "coincident"
},
{
"id": "k7",
"p": 7,
"param": "d4",
"q": 6,
"text": [
-0.2,
9.200000000000003
],
"type": "distance",
"value": 62.0
}
],
"counters": {
"j": 1,
"k": 7,
"l": 10,
"p": 9
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"link": "j1",
"type": "line"
},
{
"a": 3,
"b": 1,
"construction": false,
"id": "l2",
"link": "j1",
"type": "line"
},
{
"a": 2,
"b": 4,
"construction": false,
"id": "l3",
"link": "j1",
"type": "line"
},
{
"a": 4,
"b": 3,
"construction": false,
"id": "l4",
"link": "j1",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
},
{
"a": 5,
"b": 7,
"construction": true,
"id": "l9",
"type": "line"
},
{
"a": 6,
"b": 8,
"construction": true,
"id": "l10",
"type": "line"
}
],
"links": [
{
"auto_face": true,
"face_name": "F2:cap:high:ed6121d9bed14d3fd103f347231990d103349f33b7874ad292d3c4d3539266c3",
"id": "j1",
"kind": "project",
"source": {
"at": [
11.666666666666666,
-13.999999999999995,
30.0
],
"body": "Body1",
"type": "face"
}
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": true,
"id": "p1",
"link": "j1",
"pos": [
-35.0,
-42.0
]
},
{
"fixed": true,
"id": "p2",
"link": "j1",
"pos": [
-35.0,
42.0
]
},
{
"fixed": true,
"id": "p3",
"link": "j1",
"pos": [
35.0,
-42.0
]
},
{
"fixed": true,
"id": "p4",
"link": "j1",
"pos": [
35.0,
42.0
]
},
{
"fixed": false,
"id": "p5",
"pos": [
-35.0,
-31.000000000002185
]
},
{
"fixed": false,
"id": "p6",
"pos": [
35.0,
-31.00000000000349
]
},
{
"fixed": false,
"id": "p7",
"pos": [
35.0,
30.99999999999603
]
},
{
"fixed": false,
"id": "p8",
"pos": [
-35.0,
30.999999999995318
]
},
{
"fixed": false,
"id": "p9",
"pos": [
0.0,
0.0
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "18"
},
"id": 4,
"name": "Extrude2",
"operation": "join",
"param_names": [
"d5"
],
"profiles": {
"loops": [
[
"l1",
"l5",
"l4",
"l7"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"type": "extrude"
}
],
"format": "solidcraft/14",
"name": "Untitled",
"next_id": 6,
"params": [
{
"expr": "84",
"model": true,
"name": "d1",
"unit": "mm"
},
{
"expr": "70",
"model": true,
"name": "d2",
"unit": "mm"
},
{
"expr": "62",
"model": true,
"name": "d4",
"unit": "mm"
}
],
"units": "mm"
}
@@ -1,227 +0,0 @@
{
"bodies": [
{
"area_mm2": 8430.29544246263,
"bbox": {
"max": [
176.8000000000002,
89.30000000000001,
14.100000000000001
],
"min": [
99.99999999999991,
-1.820765760385257e-14,
0.0
]
},
"center_of_mass": [
138.3999999716193,
51.04392591016203,
2.0990634391586322
],
"density_g_cm3": 1.29,
"edges": 156,
"face_types": {
"cylinder": 4,
"plane": 50
},
"faces": 54,
"kernel": {
"edges": 156,
"faces": 54,
"shells": 1,
"vertices": 104
},
"mass_g": 9.101726874183207,
"material": "Default",
"name": "Lid",
"vertices": 104,
"volume_mm3": 7055.602228048997
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 8,
"dof": 8,
"id": 3,
"name": "Skirt",
"profiles": 2,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 1.178957,
"name": "Box1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 83.24342399999999,
"name": "Fillet1",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.150889,
"name": "Skirt",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 94.465481,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 5,
"ms": 50.426736,
"name": "Box2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 6,
"ms": 40.383005,
"name": "Box3",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 7,
"ms": 59.041461000000005,
"name": "Box4",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [
{
"face_shift_mm": 0.0,
"rotation_rad": 0.0,
"strategy": "planar_fallback",
"tolerance_mm": 0.0,
"translation_mm": 0.0
}
],
"truncated": false
},
"error": null,
"id": 8,
"ms": 562.5477970000001,
"name": "Rib1",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": "Boolean retry: exact planar fallback (no displacement)"
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 9,
"ms": 89.56076,
"name": "Rib2",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 10,
"ms": 65.330634,
"name": "Rib3",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 11,
"ms": 97.229403,
"name": "Rib4",
"rolled_back": false,
"suppressed": false,
"type": "BoxFeature",
"warning": null
}
],
"total": {
"area_mm2": 8430.29544246263,
"edges": 156,
"faces": 54,
"mass_g": 9.101726874183207,
"vertices": 104,
"volume_mm3": 7055.602228048997
}
}
@@ -1,507 +0,0 @@
{
"features": [
{
"body_names": [
"Lid"
],
"corner": [
100.0,
0.0,
0.0
],
"height": "2.4",
"id": 1,
"length": "76.8",
"name": "Box1",
"operation": "new_body",
"param_names": [
"d1",
"d2",
"d3"
],
"suppressed": false,
"type": "box",
"width": "89.3"
},
{
"edge_names": [
"F1:-x|F1:-y",
"F1:+x|F1:-y",
"F1:+y|F1:-x",
"F1:+x|F1:+y"
],
"edge_refs": [
{
"dir": [
0.0,
0.0,
-1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
},
{
"dir": [
0.0,
0.0,
1.0
],
"max": [
176.8,
89.3,
2.4
],
"min": [
100.0,
0.0,
0.0
]
}
],
"edges": [
[
100.0,
0.0,
1.2
],
[
176.8,
0.0,
1.2
],
[
100.0,
89.3,
1.2
],
[
176.8,
89.3,
1.2
]
],
"id": 2,
"name": "Fillet1",
"param_names": [
"d4"
],
"radius": "3",
"suppressed": false,
"type": "fillet"
},
{
"id": 3,
"name": "Skirt",
"param_names": [
"d5"
],
"plane": {
"base": {
"name": "XY",
"type": "origin"
},
"distance": "2.4",
"type": "offset"
},
"sketch": {
"constraints": [
{
"id": "k1",
"l": 0,
"type": "horizontal"
},
{
"id": "k2",
"l": 2,
"type": "horizontal"
},
{
"id": "k3",
"l": 1,
"type": "vertical"
},
{
"id": "k4",
"l": 3,
"type": "vertical"
},
{
"id": "k5",
"l": 4,
"type": "horizontal"
},
{
"id": "k6",
"l": 6,
"type": "horizontal"
},
{
"id": "k7",
"l": 5,
"type": "vertical"
},
{
"id": "k8",
"l": 7,
"type": "vertical"
}
],
"counters": {
"k": 8,
"l": 8,
"p": 8
},
"curves": [
{
"a": 1,
"b": 2,
"construction": false,
"id": "l1",
"type": "line"
},
{
"a": 2,
"b": 3,
"construction": false,
"id": "l2",
"type": "line"
},
{
"a": 3,
"b": 4,
"construction": false,
"id": "l3",
"type": "line"
},
{
"a": 4,
"b": 1,
"construction": false,
"id": "l4",
"type": "line"
},
{
"a": 5,
"b": 6,
"construction": false,
"id": "l5",
"type": "line"
},
{
"a": 6,
"b": 7,
"construction": false,
"id": "l6",
"type": "line"
},
{
"a": 7,
"b": 8,
"construction": false,
"id": "l7",
"type": "line"
},
{
"a": 8,
"b": 5,
"construction": false,
"id": "l8",
"type": "line"
}
],
"points": [
{
"fixed": true,
"id": "origin",
"pos": [
0.0,
0.0
]
},
{
"fixed": false,
"id": "p1",
"pos": [
102.65,
2.65
]
},
{
"fixed": false,
"id": "p2",
"pos": [
174.15,
2.65
]
},
{
"fixed": false,
"id": "p3",
"pos": [
174.15,
86.65
]
},
{
"fixed": false,
"id": "p4",
"pos": [
102.65,
86.65
]
},
{
"fixed": false,
"id": "p5",
"pos": [
104.15,
4.15
]
},
{
"fixed": false,
"id": "p6",
"pos": [
172.65,
4.15
]
},
{
"fixed": false,
"id": "p7",
"pos": [
172.65,
85.15
]
},
{
"fixed": false,
"id": "p8",
"pos": [
104.15,
85.15
]
}
]
},
"suppressed": false,
"type": "sketch"
},
{
"extent": {
"direction": "positive",
"distance": "3"
},
"id": 4,
"name": "Extrude1",
"operation": "join",
"param_names": [
"d6"
],
"profiles": {
"loops": [
[
"l1",
"l2",
"l3",
"l4"
]
],
"type": "curves"
},
"sketch": 3,
"suppressed": false,
"targets": [
"Lid"
],
"type": "extrude"
},
{
"corner": [
104.4,
5.4,
-1.0
],
"height": "8",
"id": 5,
"length": "68",
"name": "Box2",
"operation": "cut",
"param_names": [
"d7",
"d8",
"d9"
],
"suppressed": false,
"type": "box",
"width": "61"
},
{
"corner": [
116.4,
72.0,
-1.0
],
"height": "8",
"id": 6,
"length": "44",
"name": "Box3",
"operation": "cut",
"param_names": [
"d10",
"d11",
"d12"
],
"suppressed": false,
"type": "box",
"width": "10"
},
{
"corner": [
127.4,
84.0,
2.4
],
"height": "4",
"id": 7,
"length": "22",
"name": "Box4",
"operation": "cut",
"param_names": [
"d13",
"d14",
"d15"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
102.8,
14.0,
2.4
],
"height": "11.7",
"id": 8,
"length": "1.6",
"name": "Rib1",
"operation": "join",
"param_names": [
"d16",
"d17",
"d18"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
102.8,
48.0,
2.4
],
"height": "11.7",
"id": 9,
"length": "1.6",
"name": "Rib2",
"operation": "join",
"param_names": [
"d19",
"d20",
"d21"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
172.4,
14.0,
2.4
],
"height": "11.7",
"id": 10,
"length": "1.6",
"name": "Rib3",
"operation": "join",
"param_names": [
"d22",
"d23",
"d24"
],
"suppressed": false,
"type": "box",
"width": "6"
},
{
"corner": [
172.4,
48.0,
2.4
],
"height": "11.7",
"id": 11,
"length": "1.6",
"name": "Rib4",
"operation": "join",
"param_names": [
"d25",
"d26",
"d27"
],
"suppressed": false,
"type": "box",
"width": "6"
}
],
"format": "solidcraft/15",
"name": "issue-616-lid-ribs",
"next_id": 12,
"params": [],
"units": "mm"
}
@@ -1,194 +0,0 @@
{
"bodies": [
{
"area_mm2": 2400.0,
"bbox": {
"max": [
50.0,
20.0,
20.0
],
"min": [
30.0,
0.0,
0.0
]
},
"center_of_mass": [
40.0,
10.0,
10.0
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 10.32,
"material": "Default",
"name": "Block",
"vertices": 8,
"volume_mm3": 8000.0
},
{
"area_mm2": 600.0,
"bbox": {
"max": [
3.552713678800501e-15,
90.0,
10.0
],
"min": [
-9.999999999999998,
80.0,
0.0
]
},
"center_of_mass": [
-4.999999999999999,
85.0,
5.000000000000001
],
"density_g_cm3": 1.29,
"edges": 12,
"face_types": {
"plane": 6
},
"faces": 6,
"kernel": {
"edges": 12,
"faces": 6,
"shells": 1,
"vertices": 8
},
"mass_g": 1.29,
"material": "Default",
"name": "Peg",
"vertices": 8,
"volume_mm3": 1000.0
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "Peg",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 1,
"ms": 0.204585,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 2,
"ms": 2.318662,
"name": "Extrude1",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 3,
"ms": 0.109196,
"name": "Peg",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 4,
"ms": 0.338859,
"name": "Extrude2",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 5,
"ms": 0.312259,
"name": "Move1",
"rolled_back": false,
"suppressed": false,
"type": "MoveFeature",
"warning": null
},
{
"boolean_diagnostics": {
"events": [],
"truncated": false
},
"error": null,
"id": 6,
"ms": 0.23144699999999999,
"name": "Move2",
"rolled_back": false,
"suppressed": false,
"type": "MoveFeature",
"warning": null
}
],
"total": {
"area_mm2": 3000.0,
"edges": 24,
"faces": 12,
"mass_g": 11.61,
"vertices": 16,
"volume_mm3": 9000.0
}
}
@@ -1 +0,0 @@
{"features":[{"id":1,"name":"Base","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"},{"id":"k5","p":0,"q":1,"type":"coincident"}],"counters":{"k":5,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[0.0,0.0]},{"fixed":false,"id":"p2","pos":[20.0,0.0]},{"fixed":false,"id":"p3","pos":[20.0,20.0]},{"fixed":false,"id":"p4","pos":[0.0,20.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Block"],"extent":{"direction":"positive","distance":"20"},"id":2,"name":"Extrude1","operation":"new_body","param_names":["d1"],"profiles":{"type":"all"},"sketch":1,"suppressed":false,"type":"extrude"},{"id":3,"name":"Peg","plane":{"name":"XY","type":"origin"},"sketch":{"constraints":[{"id":"k1","l":0,"type":"horizontal"},{"id":"k2","l":2,"type":"horizontal"},{"id":"k3","l":1,"type":"vertical"},{"id":"k4","l":3,"type":"vertical"}],"counters":{"k":4,"l":4,"p":4},"curves":[{"a":1,"b":2,"construction":false,"id":"l1","type":"line"},{"a":2,"b":3,"construction":false,"id":"l2","type":"line"},{"a":3,"b":4,"construction":false,"id":"l3","type":"line"},{"a":4,"b":1,"construction":false,"id":"l4","type":"line"}],"points":[{"fixed":true,"id":"origin","pos":[0.0,0.0]},{"fixed":false,"id":"p1","pos":[40.0,0.0]},{"fixed":false,"id":"p2","pos":[50.0,0.0]},{"fixed":false,"id":"p3","pos":[50.0,10.0]},{"fixed":false,"id":"p4","pos":[40.0,10.0]}]},"suppressed":false,"type":"sketch"},{"body_names":["Peg"],"extent":{"direction":"positive","distance":"10"},"id":4,"name":"Extrude2","operation":"new_body","param_names":["d2"],"profiles":{"type":"all"},"sketch":3,"suppressed":false,"type":"extrude"},{"bodies":["Block"],"id":5,"name":"Move1","param_names":["d3","d4","d5"],"suppressed":false,"translate":["30","0","0"],"type":"move"},{"angle":"90 deg","bodies":["Peg"],"id":6,"name":"Move2","param_names":["d6","d7","d8","d9"],"rotate_axis":[0.0,0.0,1.0],"suppressed":false,"translate":["0","40","0"],"type":"move"}],"format":"solidcraft/15","name":"old-move-bodies","next_id":7,"params":[],"units":"mm"}
@@ -1,144 +0,0 @@
{
"bodies": [
{
"area_mm2": 4078.7680390666596,
"bbox": {
"max": [
40.0,
30.000000000000014,
12.000000000000002
],
"min": [
0.0,
0.0,
0.0
]
},
"center_of_mass": [
19.25890231218174,
15.000000010054242,
5.04117761026949
],
"density_g_cm3": 1.29,
"edges": 30,
"face_types": {
"cylinder": 1,
"plane": 11
},
"faces": 12,
"kernel": {
"edges": 30,
"faces": 12,
"shells": 1,
"vertices": 20
},
"mass_g": 14.435720327850033,
"material": "Default",
"name": "Body1",
"vertices": 20,
"volume_mm3": 11190.480874302351
}
],
"body_count": 1,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.09895799999999999,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 20.768292000000002,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.057,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 136.28875,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 153.58625,
"name": "Round",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 169.174375,
"name": "Bevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
},
{
"error": null,
"id": 7,
"ms": 47.772166,
"name": "Raise",
"rolled_back": false,
"suppressed": false,
"type": "OffsetFacesFeature",
"warning": null
}
],
"total": {
"area_mm2": 4078.7680390666596,
"edges": 30,
"faces": 12,
"mass_g": 14.435720327850033,
"vertices": 20,
"volume_mm3": 11190.480874302351
}
}
@@ -1,373 +0,0 @@
{
"format": "solidcraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
15.0,
-5.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
25.0,
-5.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
25.0,
35.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
15.0,
35.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2",
"d3"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive",
"start_offset": "5"
},
"operation": "cut"
},
{
"id": 5,
"name": "Round",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
0.0,
-1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l2|F2:top#2"
],
"type": "fillet",
"edges": [
[
40.0,
15.0,
10.0
]
],
"radius": "2"
},
{
"id": 6,
"name": "Bevel",
"suppressed": false,
"param_names": [
"d5"
],
"edge_refs": [
{
"dir": [
0.0,
1.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
30.000000000000007,
10.0
]
}
],
"edge_names": [
"F2:side:l4|F2:top#1"
],
"type": "chamfer",
"edges": [
[
0.0,
15.0,
10.0
]
],
"distance": "1.5"
},
{
"id": 7,
"name": "Raise",
"suppressed": false,
"param_names": [
"d6"
],
"face_names": [
"F2:top#1"
],
"type": "offset_face",
"faces": [
[
7.0,
15.0,
10.0
]
],
"distance": "2"
}
],
"next_id": 8
}
@@ -1,14 +0,0 @@
{
"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "Base"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [20, 20]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 20, "body_name": "Block"}},
{"command": "sketch.create", "params": {"plane": "XY", "name": "Peg"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [40, 0], "p1": [50, 10]}},
{"command": "sketch.finish", "params": {}},
{"command": "solid.extrude", "params": {"distance": 10, "operation": "new", "body_name": "Peg"}},
{"command": "solid.move", "params": {"bodies": ["Block"], "translate": [30, 0, 0]}},
{"command": "solid.move", "params": {"bodies": ["Peg"], "translate": [0, 40, 0], "axis": [0, 0, 1], "angle": "90 deg"}}
]
}
@@ -1,111 +0,0 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
15,
-5
],
"p1": [
25,
35
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"start_offset": 5,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
40,
15,
10
]
],
"radius": 2,
"name": "Round"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
0,
15,
10
]
],
"distance": 1.5,
"name": "Bevel"
}
},
{
"command": "solid.offset_face",
"params": {
"faces": [
[
7,
15,
10
]
],
"distance": 2,
"name": "Raise"
}
}
]
}
@@ -1,107 +0,0 @@
{
"commands": [
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "Base"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
0,
0
],
"p1": [
40,
30
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"distance": 10,
"name": "Block"
}
},
{
"command": "sketch.create",
"params": {
"plane": "XY",
"name": "SlotSketch"
}
},
{
"command": "sketch.rectangle.two_point",
"params": {
"p0": [
-5,
12
],
"p1": [
45,
18
]
}
},
{
"command": "sketch.finish",
"params": {}
},
{
"command": "solid.extrude",
"params": {
"sketch": "SlotSketch",
"distance": 10,
"operation": "cut",
"name": "Slot"
}
},
{
"command": "solid.fillet",
"params": {
"edges": [
[
20,
0,
10
]
],
"radius": 2,
"body": "Body1 (1)",
"name": "NearRound"
}
},
{
"command": "solid.chamfer",
"params": {
"edges": [
[
20,
30,
10
]
],
"distance": 1.5,
"body": "Body1",
"name": "FarBevel"
}
},
{
"command": "appearance.assign",
"params": {
"bodies": [
"Body1 (1)"
],
"color": "#cc3333"
}
}
]
}
@@ -1,171 +0,0 @@
{
"bodies": [
{
"area_mm2": 1962.6028137423857,
"bbox": {
"max": [
40.0,
30.0,
10.0
],
"min": [
0.0,
18.0,
0.0
]
},
"center_of_mass": [
20.0,
23.94794952681388,
4.957413249211356
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"plane": 7
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.13395,
"material": "Default",
"name": "Body1",
"vertices": 10,
"volume_mm3": 4755.0
},
{
"area_mm2": 1963.9401350108699,
"bbox": {
"max": [
40.00000000000002,
12.000000000000002,
10.0
],
"min": [
-8.881784197001252e-15,
0.0,
0.0
]
},
"center_of_mass": [
20.00000000000079,
6.040035701025577,
4.967173993940324
],
"density_g_cm3": 1.29,
"edges": 15,
"face_types": {
"cylinder": 1,
"plane": 6
},
"faces": 7,
"kernel": {
"edges": 15,
"faces": 7,
"shells": 1,
"vertices": 10
},
"mass_g": 6.147677129601969,
"material": "Default",
"name": "Body1 (1)",
"vertices": 10,
"volume_mm3": 4765.64118573796
}
],
"body_count": 2,
"params": [],
"sketches": [
{
"curves": 4,
"dof": 2,
"id": 1,
"name": "Base",
"profiles": 1,
"status": "solved",
"visible": false
},
{
"curves": 4,
"dof": 4,
"id": 3,
"name": "SlotSketch",
"profiles": 1,
"status": "solved",
"visible": false
}
],
"timeline": [
{
"error": null,
"id": 1,
"ms": 0.07066700000000001,
"name": "Base",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 2,
"ms": 0.7462909999999999,
"name": "Block",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 3,
"ms": 0.035125,
"name": "SlotSketch",
"rolled_back": false,
"suppressed": false,
"type": "Sketch",
"warning": null
},
{
"error": null,
"id": 4,
"ms": 9.647208000000001,
"name": "Slot",
"rolled_back": false,
"suppressed": false,
"type": "ExtrudeFeature",
"warning": null
},
{
"error": null,
"id": 5,
"ms": 3.443708,
"name": "NearRound",
"rolled_back": false,
"suppressed": false,
"type": "FilletFeature",
"warning": null
},
{
"error": null,
"id": 6,
"ms": 3.947834,
"name": "FarBevel",
"rolled_back": false,
"suppressed": false,
"type": "ChamferFeature",
"warning": null
}
],
"total": {
"area_mm2": 3926.5429487532556,
"edges": 30,
"faces": 14,
"mass_g": 12.281627129601969,
"vertices": 20,
"volume_mm3": 9520.64118573796
}
}
@@ -1,366 +0,0 @@
{
"format": "solidcraft/13",
"name": "Untitled",
"units": "mm",
"params": [],
"features": [
{
"id": 1,
"name": "Base",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
0.0,
0.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
40.0,
0.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
40.0,
30.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
0.0,
30.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
},
{
"id": "k5",
"type": "coincident",
"p": 0,
"q": 1
}
],
"counters": {
"k": 5,
"l": 4,
"p": 4
}
}
},
{
"id": 2,
"name": "Block",
"suppressed": false,
"param_names": [
"d1"
],
"type": "extrude",
"sketch": 1,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "new_body"
},
{
"id": 3,
"name": "SlotSketch",
"suppressed": false,
"type": "sketch",
"plane": {
"type": "origin",
"name": "XY"
},
"sketch": {
"points": [
{
"id": "origin",
"pos": [
0.0,
0.0
],
"fixed": true
},
{
"id": "p1",
"pos": [
-5.0,
12.0
],
"fixed": false
},
{
"id": "p2",
"pos": [
45.0,
12.0
],
"fixed": false
},
{
"id": "p3",
"pos": [
45.0,
18.0
],
"fixed": false
},
{
"id": "p4",
"pos": [
-5.0,
18.0
],
"fixed": false
}
],
"curves": [
{
"id": "l1",
"type": "line",
"a": 1,
"b": 2,
"construction": false
},
{
"id": "l2",
"type": "line",
"a": 2,
"b": 3,
"construction": false
},
{
"id": "l3",
"type": "line",
"a": 3,
"b": 4,
"construction": false
},
{
"id": "l4",
"type": "line",
"a": 4,
"b": 1,
"construction": false
}
],
"constraints": [
{
"id": "k1",
"type": "horizontal",
"l": 0
},
{
"id": "k2",
"type": "horizontal",
"l": 2
},
{
"id": "k3",
"type": "vertical",
"l": 1
},
{
"id": "k4",
"type": "vertical",
"l": 3
}
],
"counters": {
"k": 4,
"l": 4,
"p": 4
}
}
},
{
"id": 4,
"name": "Slot",
"suppressed": false,
"param_names": [
"d2"
],
"type": "extrude",
"sketch": 3,
"profiles": {
"type": "all"
},
"extent": {
"distance": "10",
"direction": "positive"
},
"operation": "cut"
},
{
"id": 5,
"name": "NearRound",
"suppressed": false,
"param_names": [
"d3"
],
"edge_refs": [
{
"dir": [
-1.0,
0.0,
0.0
],
"min": [
0.0,
0.0,
0.0
],
"max": [
40.0,
12.000000000000002,
10.0
]
}
],
"edge_names": [
"F2:side:l1|F2:top"
],
"type": "fillet",
"edges": [
[
20.0,
0.0,
10.0
]
],
"radius": "2",
"body": "Body1 (1)"
},
{
"id": 6,
"name": "FarBevel",
"suppressed": false,
"param_names": [
"d4"
],
"edge_refs": [
{
"dir": [
1.0,
0.0,
0.0
],
"min": [
0.0,
18.0,
0.0
],
"max": [
40.0,
30.0,
10.0
]
}
],
"edge_names": [
"F2:side:l3|F2:top"
],
"type": "chamfer",
"edges": [
[
20.0,
30.0,
10.0
]
],
"distance": "1.5",
"body": "Body1"
}
],
"next_id": 7,
"appearances": {
"bodies": {
"Body1 (1)": {
"name": "Custom",
"color": [
204,
51,
51
],
"opacity": 1.0
}
}
}
}
-423
View File
@@ -1,423 +0,0 @@
//! Designs saved by older SolidCraft builds (tests/corpus, see its README) open and evaluate as
//! they did when they were saved; damaged and hostile design files never panic.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
v.sort();
assert!(v.len() >= 7, "corpus files in {}", dir.display());
v
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// Differences that are deliberate behaviour changes made before the file format had versions
/// (no migration can tell those files apart): (file, check, why).
const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
fn known(file: &str, check: &str) -> bool {
KNOWN.iter().any(|(f, c, _)| *f == file && *c == check)
}
/// Volumes and areas then came from coarser tessellation (and curved booleans and fillets have
/// become exact since): the same part within this relative tolerance (the oracle's).
const REL: f64 = 1e-3;
fn close(a: f64, b: f64, rel: f64) -> bool {
(a - b).abs() <= rel * b.abs().max(1.0)
}
#[test]
fn old_designs_open_and_evaluate_as_saved() {
let mut problems = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let want: Value = serde_json::from_slice(&std::fs::read(p.with_extension("expected.json")).unwrap()).unwrap();
let mut s = open(&p);
for r in &s.model.results {
if let Some(e) = &r.error {
problems.push(format!("{name}: feature {}: {e}", r.id));
}
}
assert_eq!(s.doc.format, solidcraft_engine::doc::format::format_string(solidcraft_engine::doc::format::FORMAT), "{name}: upgraded");
let got = s.execute("inspect.measure", &json!({})).unwrap();
if got["body_count"] != want["body_count"] && !known(&name, "body_count") {
problems.push(format!("{name}: {} bodies, saved with {}", got["body_count"], want["body_count"]));
}
for w in want["bodies"].as_array().into_iter().flatten() {
let bname = w["name"].as_str().unwrap_or_default();
let Some(g) = got["bodies"].as_array().into_iter().flatten().find(|g| g["name"] == w["name"]) else {
problems.push(format!("{name}: body {bname} missing"));
continue;
};
for k in ["volume_mm3", "area_mm2"] {
let (a, b) = (g[k].as_f64().unwrap_or(f64::NAN), w[k].as_f64().unwrap_or(f64::NAN));
if !close(a, b, REL) {
problems.push(format!("{name}: {bname} {k} {a} (saved {b})"));
}
}
if known(&name, &format!("{bname} bbox")) {
continue;
}
for (end, i) in [("min", 0), ("min", 1), ("min", 2), ("max", 0), ("max", 1), ("max", 2)] {
let (a, b) = (g["bbox"][end][i].as_f64().unwrap_or(f64::NAN), w["bbox"][end][i].as_f64().unwrap_or(f64::NAN));
if (a - b).abs() > 1e-3 * b.abs().max(1.0) {
problems.push(format!("{name}: {bname} bbox {end}[{i}] {a} (saved {b})"));
}
}
}
// Saved again, it reads back the same.
let again = solidcraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(again, *s.doc, "{name}: round trip");
let package = solidcraft_engine::io::write_design_container(&s.doc).unwrap();
let packaged = solidcraft_engine::io::read_design(&package).unwrap();
assert_eq!(packaged, *s.doc, "{name}: container round trip after migration");
}
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// Names saved before format 14 were worked out from geometry, pieces of a split face numbered by
/// where their centres lay (`F2:top#2`). They keep naming the faces they named: marked as such,
/// they are found as the build that saved them found them, without new notes.
#[test]
fn names_saved_before_exact_history_find_the_faces_they_named() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/naming-split-pieces.solidcraft");
let s = open(&path);
let feature = |name: &str| s.doc.features.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("{name}"));
assert_eq!(feature("Round").edge_names, vec!["?F2:side:l2|F2:top#2".to_string()]);
assert_eq!(feature("Bevel").edge_names, vec!["?F2:side:l4|F2:top#1".to_string()]);
assert_eq!(feature("Raise").face_names, vec!["?F2:top#1".to_string()]);
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
// The raised piece is the left one, as saved (the stored point is on it).
let state = s.model.state();
let solid = |p: [f64; 3]| state.bodies.iter().any(|b| b.body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2])));
assert!(solid([7.0, 15.0, 11.0]) && !solid([32.0, 15.0, 11.0]));
}
/// Body names saved before format 14 name the pieces of a split body in the kernel's shell order.
/// The features after the split that refer to a piece by its body name still find that piece:
/// the far one stays `Body1` (chamfered), the near one `Body1 (1)` (filleted, coloured).
#[test]
fn body_names_saved_before_exact_history_keep_their_pieces() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus/split-body-pieces.solidcraft");
let s = open(&path);
assert!(s.doc.features.iter().all(|f| f.shell_piece_order), "every saved feature keeps the shell order");
for r in &s.model.results {
assert!(r.error.is_none() && r.warning.is_none(), "{}: {:?} {:?}", r.name, r.error, r.warning);
}
let state = s.model.state();
let piece = |name: &str| state.bodies.iter().find(|b| b.name == name).unwrap_or_else(|| panic!("{name}"));
let solid = |name: &str, p: [f64; 3]| piece(name).body.contains(solidcraft_engine::geom::Vec3::new(p[0], p[1], p[2]));
assert!(solid("Body1", [20.0, 24.0, 5.0]) && solid("Body1 (1)", [20.0, 6.0, 5.0]));
assert!(s.doc.appearances.bodies.contains_key("Body1 (1)"));
}
/// Flat faces that can't take an attached sketch yet, by cause: (design, body, the faces'
/// planes as outward normal and distance from the origin, or every flat face of the body,
/// issue). Face indices are not used: they can change between builds. Each issue has an
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
/// check until it is removed from here.
type Planes = &'static [([f64; 3], f64)];
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 1] = [("issue-616-lid-ribs", "Lid", None, 628)];
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
let same = |(n, d): &([f64; 3], f64)| (0..3).all(|i| (n[i] - normal[i]).abs() < 1e-6) && (d - distance).abs() < 1e-4;
NO_ATTACHMENT_YET.iter().find_map(|(d, b, planes, issue)| {
if *d != design || *b != body {
return None;
}
match planes {
None => Some((*issue, false)),
Some(planes) => planes.iter().any(same).then_some((*issue, true)),
}
})
}
fn undo_to(s: &mut Session, features: usize) {
for _ in 0..4 {
if s.doc.features.len() <= features {
return;
}
s.execute("edit.undo", &json!({})).unwrap();
}
assert_eq!(s.doc.features.len(), features, "undo did not remove the added feature");
}
/// Try a sketch and an offset plane on every flat face of every final body of every corpus
/// design, through the same face pick the desktop sends (body, face index, occurrence path, a
/// point on it), without `fixed`. Undo leaves the design as it was for the next face. Returns
/// (design, body, face, issue it is known under, what went wrong or None).
fn flat_face_attachments() -> Vec<(String, String, usize, Option<(u32, bool)>, Option<String>)> {
use solidcraft_engine::doc::{FaceAttachmentStatus, apply_point};
let mut out = Vec::new();
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = open(&p);
let state = s.model.state();
let placed = s.placed_document();
let mut picks = Vec::new();
for (world, definition, component, path, frame) in placed.body_frames_with_source(&state) {
let Some(body) = state.bodies.iter().find(|b| b.definition_name() == definition && placed.model_body_component(b) == component) else {
continue;
};
let mesh = body.body.tessellate(0.01).unwrap();
for index in 0..body.body.face_count() {
if body.body.planar_face(index).is_none() {
continue;
}
// The middle of the face's largest triangle: well inside the face.
let point = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, f)| **f as usize == index)
.filter_map(|(t, _)| mesh.tri(t))
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0));
let Some(point) = point else { continue };
let point = apply_point(&frame, point);
let normal =
body.body.planar_face(index).map(|plane| solidcraft_engine::doc::apply_vector(&frame, plane.normal())).unwrap_or_default();
let key = ([normal.x, normal.y, normal.z], normal.dot(point));
picks.push((world.clone(), index, key, json!({"face": point, "body": world, "index": index, "path": path})));
}
}
let before = s.doc.features.len();
for (body, index, (normal, distance), plane) in picks {
let mut problem = None;
match s.execute("sketch.create", &json!({"plane": plane})) {
Ok(created) => {
let id = created["sketch"].as_u64().unwrap_or(0);
match s.placed_document().sketch_face_attachment(id, &s.model) {
FaceAttachmentStatus::Healthy { .. } | FaceAttachmentStatus::Sheet { .. } => {}
other => problem = Some(format!("sketch attachment {other:?}")),
}
s.execute("sketch.finish", &json!({})).unwrap();
undo_to(&mut s, before);
}
Err(e) => problem = Some(format!("sketch.create: {e}")),
}
match s.execute("construct.plane.offset", &json!({"base": plane, "offset": 5})) {
Ok(_) => undo_to(&mut s, before),
Err(e) => problem = Some(problem.map_or(String::new(), |p| p + "; ") + &format!("construct.plane.offset: {e}")),
}
let known = known_without_attachment(&name, &body, normal, distance);
out.push((name.clone(), body, index, known, problem));
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{name}: left with errors after undo");
}
out
}
/// Every flat face of every corpus design takes a durable attachment (issue #489: the top face
/// of an extrude sketched over a face's projected boundary had "no exact planar source"),
/// except the faces listed in [`NO_ATTACHMENT_YET`] under their open issues.
#[test]
fn every_flat_face_of_every_corpus_design_takes_an_attached_sketch() {
let all = flat_face_attachments();
assert!(all.len() > 50, "only {} faces checked", all.len());
let problems: Vec<String> = all
.iter()
.filter_map(|(design, body, face, known, problem)| match (known, problem) {
(None, Some(problem)) => Some(format!("{design}: {body} face {face}: {problem}")),
(Some((issue, true)), None) => Some(format!("{design}: {body} face {face} attaches now: remove it from NO_ATTACHMENT_YET (#{issue})")),
_ => None,
})
.collect();
assert!(problems.is_empty(), "{} of {} faces:\n{}", problems.len(), all.len(), problems.join("\n"));
}
fn known_faces_attach(issue: u32) {
let problems: Vec<String> = flat_face_attachments()
.into_iter()
.filter(|(.., known, problem)| known.is_some_and(|(known, _)| known == issue) && problem.is_some())
.map(|(design, body, face, _, problem)| format!("{design}: {body} face {face}: {}", problem.unwrap_or_default()))
.collect();
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
#[test]
#[ignore = "fails until #628 is fixed"]
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
known_faces_attach(628);
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
}
const HOSTILE: [&str; 14] =
["0", "-1", "1e308", "-1e308", "1e9", "4294967297", "\"\"", "\"1/0\"", "\"((((1\"", "null", "true", "[]", "{}", "\"nope\""];
/// Every JSON value in a document, as a path of keys and indices.
fn paths(v: &Value, at: &mut Vec<Value>, out: &mut Vec<Vec<Value>>) {
out.push(at.clone());
match v {
Value::Object(m) => {
for (k, x) in m {
at.push(json!(k));
paths(x, at, out);
at.pop();
}
}
Value::Array(a) => {
for (i, x) in a.iter().enumerate() {
at.push(json!(i));
paths(x, at, out);
at.pop();
}
}
_ => {}
}
}
fn at_mut<'a>(v: &'a mut Value, path: &[Value]) -> Option<&'a mut Value> {
let mut cur = v;
for k in path {
cur = match k {
Value::String(s) => cur.get_mut(s.as_str())?,
Value::Number(n) => cur.get_mut(n.as_u64()? as usize)?,
_ => return None,
};
}
Some(cur)
}
/// Open a file's bytes as File → Open does and evaluate them; any panic is a failure.
fn try_bytes(bytes: &[u8]) {
if let Ok(doc) = solidcraft_engine::io::read_design(bytes) {
let mut s = Session::new(doc);
let _ = s.execute("inspect.measure", &json!({}));
let _ = s.execute("document.inspect", &json!({}));
}
}
/// Hostile shapes that no mutation of a saved file produces: plane references that close a
/// cycle, which resolve through the model and back into the document.
fn generated() -> Vec<(String, Vec<u8>)> {
let to = |name: &str| json!({"type": "plane", "plane": {"type": "construction", "name": name}});
let plane = |id: u64, name: &str, side: Value| {
json!({"id": id, "name": name, "suppressed": false, "type": "construction_plane",
"plane": {"type": "midplane", "a": side, "b": {"type": "plane", "plane": {"type": "origin", "name": "XZ"}}}})
};
let design = |name: &str, features: Value| {
json!({"format": "solidcraft/11", "name": name, "units": "mm", "params": [], "next_id": 9, "features": features}).to_string().into_bytes()
};
// A sketch past the caps interactive editing enforces: solving it would size dense matrices
// by the point count and abort on the allocation.
let point = json!({"id": "p1", "pos": [0.0, 0.0], "fixed": false});
let oversized = json!([{"id": 1, "name": "Sketch1", "suppressed": false, "type": "sketch",
"plane": {"type": "origin", "name": "XY"},
"sketch": {"points": vec![point; 200_000], "curves": [], "constraints": []}}]);
vec![
("construction plane is its own side".into(), design("selfplane", json!([plane(1, "P1", to("P1"))]))),
("two construction planes refer to each other".into(), design("twoplanes", json!([plane(1, "P1", to("P2")), plane(2, "P2", to("P1"))]))),
("sketch with 200,000 points".into(), design("bigsketch", oversized)),
]
}
#[test]
fn damaged_and_hostile_design_files_never_panic() {
let mut rng = Rng(0x5eed_cafe_f00d_d00d);
// The cases come from one seeded sequence; trying them is independent, so threads share it.
let mut all_cases: Vec<(String, String, Vec<u8>)> = Vec::new();
for p in corpus() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let bytes = std::fs::read(&p).unwrap();
let doc: Value = serde_json::from_slice(&bytes).unwrap();
let mut all = Vec::new();
paths(&doc, &mut Vec::new(), &mut all);
let mut cases: Vec<(String, Vec<u8>)> = Vec::new();
// Truncated and bit-flipped files.
for _ in 0..8 {
let cut = rng.below(bytes.len());
cases.push((format!("truncated at {cut}"), bytes[..cut].to_vec()));
let mut b = bytes.clone();
let i = rng.below(b.len());
b[i] ^= 1 << rng.below(8);
cases.push((format!("bit flip at {i}"), b));
}
// Values replaced by hostile ones, and keys removed.
for _ in 0..25 {
let path = &all[rng.below(all.len())];
let mut v = doc.clone();
let h = HOSTILE[rng.below(HOSTILE.len())];
if let Some(slot) = at_mut(&mut v, path) {
*slot = serde_json::from_str(h).unwrap();
}
cases.push((format!("{path:?} = {h}"), v.to_string().into_bytes()));
let mut v = doc.clone();
if let Some((last, parent)) = path.split_last()
&& let (Some(Value::Object(m)), Value::String(k)) = (at_mut(&mut v, parent), last)
{
m.remove(k);
}
cases.push((format!("{path:?} removed"), v.to_string().into_bytes()));
}
// Features repeated many times; deep nesting; not JSON at all.
let mut v = doc.clone();
if let Some(Value::Array(fs)) = v.get_mut("features") {
let copy = fs.clone();
for _ in 0..2 {
fs.extend(copy.iter().cloned());
}
}
cases.push(("features repeated".into(), v.to_string().into_bytes()));
cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes()));
cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect()));
all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b)));
}
all_cases.extend(generated().into_iter().map(|(what, b)| ("generated".to_string(), what, b)));
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(4, |n| n.get());
let failures: Vec<String> = std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads)
.map(|_| {
scope.spawn(|| {
let mut failures = Vec::new();
while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
if std::env::var_os("CORPUS_TRACE").is_some() {
eprintln!("{name}: {what}");
}
if std::panic::catch_unwind(|| try_bytes(b)).is_err() {
failures.push(format!("{name}: {what}"));
}
}
failures
})
})
.collect();
workers.into_iter().flat_map(|w| w.join().unwrap()).collect()
});
assert!(failures.is_empty(), "panics:\n{}", failures.join("\n"));
}
-31
View File
@@ -1,31 +0,0 @@
//! Seed ranges must not crash or wrap, including at the last representable seed.
use std::process::Command;
#[test]
fn overflowing_seed_range_is_an_error_not_a_panic() {
let output =
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("seed range exceeds u64::MAX"), "{stderr}");
assert!(!stderr.contains("panicked"), "{stderr}");
}
#[test]
fn empty_seed_range_at_maximum_is_valid() {
let output =
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("0 seeds, 0 design steps,"));
}
#[test]
fn maximum_seed_runs_once_without_wrapping() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(String::from_utf8_lossy(&output.stdout).starts_with("1 seeds,"));
}
-168
View File
@@ -1,168 +0,0 @@
//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes
//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a
//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box, and a parameter whose comment has a character outside ASCII.
fn script(&self) -> PathBuf {
let path = self.0.join("box.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}},
{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
/// A filleted box whose fillet a later parameter change makes impossible: every command
/// succeeds, the timeline ends with a failing feature.
fn broken_script(&self) -> PathBuf {
let path = self.0.join("broken.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn stdout_of(output: Output) -> Vec<u8> {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
}
/// Timings differ from one run to the next.
fn without_timings(v: &mut Value) {
match v {
Value::Object(map) => {
map.remove("ms");
map.values_mut().for_each(without_timings);
}
Value::Array(items) => items.iter_mut().for_each(without_timings),
_ => {}
}
}
/// The file holds what stdout carried in the same command without `--json-out`: the same JSON,
/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict
/// reader accepts it.
fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value {
let bytes = std::fs::read(file).unwrap();
assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark");
assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline");
let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count();
assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout");
let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
let mut printed: Value = serde_json::from_slice(stdout).unwrap();
without_timings(&mut value);
without_timings(&mut printed);
assert_eq!(value, printed, "the file is what stdout would have carried");
value
}
#[test]
fn run_results_json_out_holds_what_stdout_would() {
let dir = Scratch::new();
let script = dir.script();
let file = dir.0.join("results.json");
let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap());
let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout");
let results = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(results.as_array().map(Vec::len), Some(2));
}
#[test]
fn run_json_out_without_results_holds_the_measurement_even_when_quiet() {
let dir = Scratch::new();
let script = dir.script();
let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap());
for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] {
let file = dir.0.join(format!("measure-{name}.json"));
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "{name}");
let measure = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(measure["body_count"], 1, "{name}");
}
}
#[test]
fn eval_json_out_holds_what_stdout_would() {
let dir = Scratch::new();
let script = dir.script();
let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap());
// The option may come before the file to evaluate, and `inspect` is the same command.
for name in ["eval", "inspect"] {
let file = dir.0.join(format!("{name}.json"));
let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
assert!(output.stdout.is_empty(), "{name}");
let measured = assert_plain_utf8_json(&file, &on_stdout);
assert_eq!(measured["body_count"], 1, "{name}");
// A character outside ASCII is written as it is, in UTF-8.
assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}");
}
}
#[test]
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
let dir = Scratch::new();
let script = dir.script();
for cmd in ["run", "eval"] {
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
let missing = dir.0.join("missing").join("out.json");
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{cmd}");
assert!(!output.stderr.is_empty(), "{cmd}");
assert!(output.stdout.is_empty(), "{cmd}");
assert!(!missing.exists(), "{cmd}");
}
}
#[test]
fn run_json_out_is_not_written_when_a_feature_fails() {
let dir = Scratch::new();
let script = dir.broken_script();
for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] {
let file = dir.0.join(format!("failed-{name}.json"));
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{name}");
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}");
assert!(!file.exists(), "{name}");
}
}
-20
View File
@@ -1,20 +0,0 @@
//! The `solvecraft-cli` compatibility alias forwards to `solidcraft-cli`.
use std::process::Command;
#[test]
fn legacy_alias_forwards_with_a_deprecation_note() {
let out = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("--version").output().unwrap();
assert!(out.status.success(), "{out:?}");
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim(), format!("solidcraft-cli {}", env!("CARGO_PKG_VERSION")));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("deprecated") && stderr.contains("solidcraft-cli"), "{stderr}");
}
#[test]
fn legacy_alias_passes_the_exit_status_through() {
let alias = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("no-such-command").output().unwrap();
let real = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("no-such-command").output().unwrap();
assert_eq!(alias.status.code(), real.status.code());
assert_eq!(alias.stdout, real.stdout);
}
-83
View File
@@ -1,83 +0,0 @@
//! `mcp --connect` to a port where no app listens yet (#270): the server still starts, its tools
//! say how to start the app, and once the app runs they reach it with that launch's token.
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
#[test]
fn mcp_connect_serves_before_the_app_runs_and_reaches_it_once_it_does() {
// A port that was free a moment ago: nothing listens on it.
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
let addr = format!("127.0.0.1:{port}");
let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-connect-{}-{port}", std::process::id()));
std::fs::create_dir_all(&control_dir).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.args(["mcp", "--connect", &addr, "--allow-any-path"])
.env("SOLIDCRAFT_CONTROL_DIR", &control_dir)
.env_remove("SOLIDCRAFT_CONTROL_TOKEN")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
let mut ask = |id: u64, method: &str, params: Value| -> Value {
writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap();
let mut line = String::new();
stdout.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}"))
};
let measure = json!({"name": "measure", "arguments": {}});
let text = |r: &Value| r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
// The server answers without the app, and its instructions say the app isn't running.
let init = ask(1, "initialize", json!({"protocolVersion": "2025-06-18"}));
let instructions = init["result"]["instructions"].as_str().unwrap_or_default();
assert!(instructions.contains("not running") && instructions.contains(&format!("--control {port}")), "{instructions}");
let tools = ask(2, "tools/list", json!({}));
assert!(tools["result"]["tools"].as_array().is_some_and(|t| !t.is_empty()), "{tools}");
// A tool call says the app is not running and how to start it.
let r = ask(3, "tools/call", measure.clone());
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(text(&r).contains("not running") && text(&r).contains(&format!("solidcraft --control {port}")), "{r}");
// The app starts: a token file for this launch, and its control port.
let token = "0123456789abcdef".repeat(4);
std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap();
let listener = TcpListener::bind(&addr).unwrap();
let app = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut line = String::new();
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
let req: Value = serde_json::from_str(&line).unwrap();
let reply = if req["token"] == json!(token) {
json!({"id": req["id"], "ok": true, "result": {"bodies": [], "method": req["method"]}})
} else {
json!({"id": req["id"], "ok": false, "error": format!("unauthorized: token {}", req["token"])})
};
writeln!(&stream, "{reply}").unwrap();
});
let r = ask(4, "tools/call", measure);
assert_ne!(r["result"]["isError"], true, "{r}");
assert!(text(&r).contains("engine.execute"), "{r}");
app.join().unwrap();
drop(stdin);
assert!(child.wait().unwrap().success());
std::fs::remove_dir_all(&control_dir).ok();
}
#[test]
fn mcp_connect_without_a_port_is_refused_at_start() {
for addr in ["localhost", "127.0.0.1:0"] {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["mcp", "--connect", addr]).stdin(Stdio::null()).output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!output.status.success(), "{addr}: {stderr}");
assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}");
}
}
@@ -1,203 +0,0 @@
//! A parameter change gives the same topology as building at the new value from scratch, and
//! changing it back gives the original topology: no stale splits, no faces added to regions
//! nothing touched. Over the design corpus (`tests/corpus`, `tests/action_matrix/designs`), and
//! the README demo's Raspberry Pi case pinned step by step.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
/// A folder of its own for test `name` (tests run side by side).
fn scratch_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-rebuild-topology-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// A body's topology: its face and edge counts, and how many faces lie in each plane.
#[derive(Debug, PartialEq, Eq)]
struct Topology {
faces: usize,
edges: usize,
planes: BTreeMap<[i64; 4], usize>,
}
/// Every solid body's topology, by name.
fn topology(s: &Session) -> BTreeMap<String, Topology> {
let st = s.world_state();
st.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.map(|b| {
let mut planes = BTreeMap::new();
for i in 0..b.body.face_count() {
if let Some(FaceSurface::Plane { normal, point }) = face_surface(&b.body, i) {
let key = [normal.x, normal.y, normal.z, normal.dot(point) / 100.].map(|x| (x * 1e4).round() as i64);
*planes.entry(key).or_insert(0) += 1;
}
}
(b.name.clone(), Topology { faces: b.body.face_count(), edges: b.body.edge_count(), planes })
})
.collect()
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// The design built from scratch: saved as it is (timeline only, no cached geometry) and
/// opened again.
fn rebuilt(s: &Session, path: &Path) -> Session {
let mut t = s.scratch();
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
open(path)
}
fn errors(s: &Session) -> Vec<u64> {
s.model.results.iter().filter(|r| r.error.is_some()).map(|r| r.id).collect()
}
/// The corpus's designs, as sessions.
fn designs() -> Vec<(String, Session)> {
let root = root();
let mut out = Vec::new();
for dir in ["apps/solidcraft-cli/tests/corpus", "tests/action_matrix/designs"] {
let mut files: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "solidcraft" || x == "json") && !p.to_string_lossy().ends_with(".expected.json"))
.collect();
files.sort();
for p in files {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let s = if p.extension().is_some_and(|x| x == "solidcraft") {
open(&p)
} else {
let mut s = Session::default();
let script: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
s.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
if s.active_sketch.is_some() {
let _ = s.execute("sketch.finish", &json!({}));
}
s
};
out.push((name, s));
}
}
out
}
/// For each design and its first length parameter that edits cleanly (the user's first): the parameter
/// scaled by 1.1 gives what a fresh build at that value gives, and setting it back gives the
/// original topology.
#[test]
fn a_parameter_edit_rebuilds_like_a_fresh_build_and_reverts_exactly() {
let dir = scratch_dir("corpus");
let mut problems = Vec::new();
let mut checked = 0;
for (name, s) in designs() {
let original = topology(&s);
let before = errors(&s);
let mut params: Vec<_> = s.doc.params.iter().filter(|p| p.unit == "mm").cloned().collect();
params.sort_by_key(|p| p.model);
let mut tried = 0;
for p in params {
if tried == 1 {
break;
}
let mut t = s.scratch();
let expr = format!("({}) * 1.1", p.expr);
if t.execute("parameters.change", &json!({"name": p.name, "expression": expr})).is_err() || errors(&t) != before {
continue;
}
tried += 1;
checked += 1;
let edited = topology(&t);
let fresh = topology(&rebuilt(&t, &dir.join(format!("{name}-{}.solidcraft", p.name))));
if edited != fresh {
problems.push(format!("{name}: {} = {expr}: edited {edited:?}\n fresh {fresh:?}", p.name));
}
t.execute("parameters.change", &json!({"name": p.name, "expression": p.expr})).unwrap();
let back = topology(&t);
if back != original {
problems.push(format!("{name}: {} back to {}: {back:?}\n original {original:?}", p.name, p.expr));
}
}
}
let _ = std::fs::remove_dir_all(&dir);
assert!(checked >= 8, "only {checked} parameter edits checked");
assert!(problems.is_empty(), "{}", problems.join("\n"));
}
/// The README demo's Raspberry Pi case (`examples/demo/enclosure_ui_mcp.py --story pi`): its
/// face count after each feature, and each outer side (the four walls and the bottom) one face
/// throughout: (a) built at height 24, (b) built at 24 and changed to 32 in Parameters, (c)
/// built at 32. The port cut-outs and the floor vents used to split the long wall (or the end
/// wall at 24) into strips along the openings' tops and bottoms.
#[test]
fn pi_case_faces_per_step_at_both_heights() {
// Sketch1 (1) .. RectangularPattern2 (14): the faces once features 1..=k are built.
const FACES: [(usize, usize); 13] =
[(2, 6), (3, 10), (4, 19), (5, 19), (6, 25), (7, 43), (8, 43), (9, 55), (10, 55), (11, 59), (12, 59), (13, 63), (14, 79)];
let dir = scratch_dir("pi");
let design: Value = serde_json::from_slice(&std::fs::read(root().join("tests/action_matrix/designs/pi-case.solidcraft")).unwrap()).unwrap();
let at = |height: &str, file: &str| {
let mut d = design.clone();
for p in d["params"].as_array_mut().unwrap() {
if p["name"] == "height" {
p["expr"] = json!(height);
}
}
let path = dir.join(file);
std::fs::write(&path, serde_json::to_vec(&d).unwrap()).unwrap();
open(&path)
};
let a = at("24 mm", "pi-24.solidcraft");
let mut b = at("24 mm", "pi-24-to-32.solidcraft");
b.execute("parameters.change", &json!({"name": "height", "expression": "32 mm"})).unwrap();
let c = at("32 mm", "pi-32.solidcraft");
let _ = std::fs::remove_dir_all(&dir);
let mut per_case = Vec::new();
for (case, s, h) in [("(a) at 24", a, 24.), ("(b) 24 changed to 32", b, 32.), ("(c) at 32", c, 32.)] {
assert!(errors(&s).is_empty(), "{case}: features fail: {:?}", errors(&s));
let mut steps = Vec::new();
for (k, want) in FACES {
let mut t = s.scratch();
t.execute("timeline.rollback", &json!({"position": k})).unwrap();
let st = t.world_state();
let body = st.bodies.iter().find(|b| b.name == "Body1").unwrap();
assert_eq!(body.body.face_count(), want, "{case}: faces after feature {k}");
// Each outer side: the faces in its plane.
for (n, d) in [([0., -1., 0.], 33.), ([0., 1., 0.], 33.), ([-1., 0., 0.], 47.5), ([1., 0., 0.], 47.5), ([0., 0., -1.], 0.)] {
let on = (0..body.body.face_count())
.filter(|&i| match face_surface(&body.body, i) {
Some(FaceSurface::Plane { normal, point }) => {
(normal.x - n[0]).abs() + (normal.y - n[1]).abs() + (normal.z - n[2]).abs() < 1e-6 && (normal.dot(point) - d).abs() < 1e-6
}
_ => false,
})
.count();
assert_eq!(on, 1, "{case}: after feature {k} the outer side facing {n:?} is {on} faces");
}
let bbox_top = body.mesh().bounds().max.z;
assert!((bbox_top - h).abs() < 1e-6, "{case}: height {bbox_top}");
steps.push(topology(&t));
}
per_case.push((case, steps));
}
// (b) and (c): the same topology after every feature.
let (b, c) = (&per_case[1].1, &per_case[2].1);
for (k, (x, y)) in b.iter().zip(c).enumerate() {
assert_eq!(x, y, "after feature {}: (b) differs from (c)", k + 2);
}
}
-172
View File
@@ -1,172 +0,0 @@
//! Result reporting must not skip requested files or execute a script twice.
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
fn script(&self) -> PathBuf {
let path = self.0.join("box.json");
std::fs::write(&path, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
fn json_output(output: Output) -> Value {
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
serde_json::from_slice(&output.stdout).unwrap()
}
fn body_count(path: &Path) -> Value {
json_output(cli().arg("eval").arg(path).output().unwrap())["body_count"].clone()
}
#[test]
fn cli_loads_both_design_encodings_by_contents() {
let dir = Scratch::new();
for extension in ["solidcraft", "solvecraft", "json"] {
let saved = dir.0.join(format!("box.{extension}"));
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
let bytes = std::fs::read(&saved).unwrap();
assert_eq!(solidcraft_engine::io::is_design_container(&bytes), extension != "json");
let renamed = dir.0.join(format!("{extension}.unusual"));
std::fs::write(&renamed, bytes).unwrap();
let measured = json_output(cli().arg("eval").arg(&renamed).output().unwrap());
assert_eq!(measured["body_count"], 1);
assert!((measured["bodies"][0]["volume_mm3"].as_f64().unwrap() - 24.0).abs() < 1e-6);
}
}
#[test]
fn results_still_save_and_export_the_script_once() {
let dir = Scratch::new();
// JSON is explicit here because the fixture edits its saved format tag.
let saved = dir.0.join("box.json");
let stl = dir.0.join("box.stl");
let obj = dir.0.join("box.obj");
let result = json_output(
cli().arg("run").arg(dir.script()).arg("--results").arg("--save").arg(&saved).arg("--out").arg(&stl).arg("--out").arg(&obj).output().unwrap(),
);
assert_eq!(result.as_array().unwrap().len(), 1);
assert_eq!(body_count(&saved), 1);
for exported in [&stl, &obj] {
assert!(std::fs::metadata(exported).unwrap().len() > 0);
assert_eq!(body_count(exported), 1);
}
}
#[test]
fn results_with_base_design_and_quiet_still_report_and_save() {
let dir = Scratch::new();
let script = dir.script();
let base = dir.0.join("base.solidcraft");
let saved = dir.0.join("combined.solidcraft");
json_output(cli().arg("run").arg(&script).arg("--save").arg(&base).output().unwrap());
let result = json_output(cli().args(["run", "--results", "--quiet", "--in"]).arg(&base).arg(&script).arg("--save").arg(&saved).output().unwrap());
assert_eq!(result.as_array().unwrap().len(), 1);
assert_eq!(body_count(&saved), 2);
assert_eq!(body_count(&base), 1);
}
#[test]
fn results_propagate_output_errors() {
let dir = Scratch::new();
for flag in ["--save", "--out"] {
let path = dir.0.join("missing").join(if flag == "--save" { "box.solidcraft" } else { "box.stl" });
let output = cli().arg("run").arg(dir.script()).arg("--results").arg(flag).arg(&path).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!output.stderr.is_empty());
assert!(output.stdout.is_empty());
assert!(!path.exists());
}
}
#[test]
fn results_without_script_still_fail() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let output = cli().args(["run", "--results", "--in"]).arg(&base).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("--results needs a script"));
}
/// Compatibility shim (docs/compatibility.md): a design saved before the rename, with the
/// legacy extension and format tag, still opens; re-saving writes the current tag.
#[test]
fn legacy_extension_and_format_tag_still_open() {
let dir = Scratch::new();
// JSON is explicit here because the fixture edits its saved format tag.
let saved = dir.0.join("box.json");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&saved).output().unwrap());
let text = std::fs::read_to_string(&saved).unwrap();
assert!(text.contains("\"solidcraft/"), "new designs carry the current tag");
let legacy = dir.0.join("old.solvecraft");
std::fs::write(&legacy, text.replace("\"solidcraft/", "\"solvecraft/")).unwrap();
assert_eq!(body_count(&legacy), 1);
let resaved = dir.0.join("resaved.solidcraft");
json_output(cli().args(["run", "--in"]).arg(&legacy).arg(dir.script()).arg("--save").arg(&resaved).output().unwrap());
assert_eq!(body_count(&resaved), 2);
let reopened = solidcraft_engine::io::read_design(&std::fs::read(&resaved).unwrap()).unwrap();
assert!(reopened.to_json().contains("\"solidcraft/"));
}
/// `snapshot` starts from `--in` as `run` does (its script runs on that design), and refuses an
/// option it doesn't take rather than ignoring it.
#[test]
fn snapshot_runs_its_script_on_the_in_design() {
let dir = Scratch::new();
let base = dir.0.join("base.solidcraft");
json_output(cli().arg("run").arg(dir.script()).arg("--save").arg(&base).output().unwrap());
let shifted = dir.0.join("shifted.json");
std::fs::write(
&shifted,
json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4, "corner": [10, 0, 0]}}]}).to_string(),
)
.unwrap();
let shot = |name: &str, args: &[&std::ffi::OsStr]| {
let png = dir.0.join(name);
json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap());
std::fs::read(&png).unwrap()
};
let alone = shot("alone.png", &[shifted.as_os_str()]);
let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]);
let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]);
assert_ne!(alone, on_base, "the script ran on the --in design");
assert_ne!(base_only, on_base);
for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] {
let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains(says), "{}", String::from_utf8_lossy(&output.stderr));
}
}
/// An unknown command's error suggests the id that was meant.
#[test]
fn unknown_command_suggests_the_closest_id() {
let output = cli().args(["exec", "file.open"]).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("did you mean `doc.open`?"), "{}", String::from_utf8_lossy(&output.stderr));
}
-90
View File
@@ -1,90 +0,0 @@
//! A timeline feature that fails fails `run`, and nothing is written: the bodies lack what the
//! feature should have made, and handing them out with exit 0 hides the failure.
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
static NEXT: AtomicUsize = AtomicUsize::new(0);
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-run-strict-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A filleted box whose fillet a later parameter change makes impossible: every command
/// succeeds, the timeline ends with a failing feature.
fn broken_script(&self) -> PathBuf {
let path = self.0.join("broken.json");
let script = json!({"commands": [
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
]});
std::fs::write(&path, script.to_string()).unwrap();
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
}
#[test]
fn a_failing_feature_fails_the_run_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.broken_script();
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
for extra in [&[][..], &["--results"][..], &["--quiet"][..]] {
let output = cli().arg("run").arg(&script).args(extra).arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
assert_eq!(output.status.code(), Some(1), "{extra:?}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Fillet1") && stderr.contains("nothing was written"), "{stderr}");
assert!(output.stdout.is_empty(), "{extra:?}");
assert!(!step.exists() && !saved.exists(), "{extra:?}");
}
}
#[test]
fn allow_errors_writes_anyway_and_a_saved_design_keeps_failing() {
let dir = Scratch::new();
let (step, saved) = (dir.0.join("part.step"), dir.0.join("part.solidcraft"));
let output = cli().arg("run").arg(dir.broken_script()).arg("--allow-errors").arg("--out").arg(&step).arg("--save").arg(&saved).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let measured: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(measured["total"]["volume_mm3"], json!(4000.0), "the box without its fillet");
assert!(std::fs::metadata(&step).unwrap().len() > 0);
// The saved design still holds the failing feature: running it fails too, `eval` reports it.
let again = dir.0.join("again.step");
let output = cli().arg("run").arg(&saved).arg("--out").arg(&again).output().unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(!again.exists());
let output = cli().arg("eval").arg(&saved).output().unwrap();
assert!(output.status.success());
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert!(report["timeline"].as_array().unwrap().iter().any(|f| f["error"].is_string()), "{}", report["timeline"]);
}
#[test]
fn a_clean_timeline_still_writes() {
let dir = Scratch::new();
let script = dir.0.join("box.json");
std::fs::write(&script, json!({"commands": [{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}}]}).to_string()).unwrap();
let step = dir.0.join("box.step");
let output = cli().arg("run").arg(&script).arg("--out").arg(&step).arg("--quiet").output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(std::fs::metadata(&step).unwrap().len() > 0);
}
@@ -1,86 +0,0 @@
//! A design's command script (`timeline.export_script`, #886) replays into the same design:
//! every corpus design that the script supports comes back with the same timeline, parameters,
//! settings and bodies.
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use solidcraft_engine::Session;
fn corpus() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut v: Vec<PathBuf> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "solidcraft")).collect();
v.sort();
assert!(v.len() >= 7, "corpus files in {}", dir.display());
v
}
/// Designs with components or joints, which scripts do not support yet.
const ASSEMBLIES: [&str; 2] = ["components-arm", "joints-hinge"];
fn bodies(s: &mut Session) -> Vec<(String, f64, f64, Value)> {
let m = s.execute("inspect.measure", &json!({})).unwrap();
let mut v: Vec<_> = m["bodies"]
.as_array()
.into_iter()
.flatten()
.map(|b| {
(
b["name"].as_str().unwrap_or_default().to_string(),
b["volume_mm3"].as_f64().unwrap(),
b["area_mm2"].as_f64().unwrap(),
b["faces"].clone(),
)
})
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
fn errors(s: &Session) -> Vec<(u64, Option<String>)> {
s.model.results.iter().map(|r| (r.id, r.error.clone())).collect()
}
fn params(s: &Session) -> Vec<solidcraft_engine::doc::Param> {
let mut v = s.doc.params.clone();
v.sort_by(|a, b| a.name.cmp(&b.name));
v
}
#[test]
fn corpus_designs_replay_from_their_scripts() {
let mut replayed = 0;
for p in corpus() {
let name = p.file_stem().unwrap_or_default().to_string_lossy().to_string();
let mut s = Session::default();
s.execute("doc.open", &json!({"path": p.to_string_lossy()})).unwrap();
let r = match s.execute("timeline.export_script", &json!({})) {
Ok(r) => r,
Err(e) => {
assert!(ASSEMBLIES.contains(&name.as_str()), "{name}: {e}");
assert!(e.to_string().contains("not supported yet"), "{name}: {e}");
continue;
}
};
assert!(!ASSEMBLIES.contains(&name.as_str()), "{name} has components: the script should refuse it");
// Through text, as a script file would carry it.
let script: Value = serde_json::from_str(&r["script"].to_string()).unwrap();
let mut t = Session::default();
t.run_script(&script).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(t.doc.features, s.doc.features, "{name}: timeline");
assert_eq!(params(&t), params(&s), "{name}: parameters");
assert_eq!(t.doc.marker, s.doc.marker, "{name}: marker");
assert_eq!(t.doc.units, s.doc.units, "{name}: units");
assert_eq!(t.doc.sheet, s.doc.sheet, "{name}: sheet metal rules");
assert_eq!(t.doc.plastic, s.doc.plastic, "{name}: plastic rules");
assert_eq!(t.doc.materials, s.doc.materials, "{name}: materials");
assert_eq!(t.doc.user_materials, s.doc.user_materials, "{name}: user materials");
assert_eq!(t.doc.favorites, s.doc.favorites, "{name}: favourites");
assert_eq!(t.doc.param_comments, s.doc.param_comments, "{name}: comments");
assert_eq!(errors(&t), errors(&s), "{name}: feature results");
assert_eq!(bodies(&mut t), bodies(&mut s), "{name}: bodies");
replayed += 1;
}
assert!(replayed >= 7, "replayed {replayed} corpus designs");
}
@@ -1,217 +0,0 @@
//! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the
//! design or one the script applies itself, and without the ground grid and the origin's axes
//! when asked (the grid lies on Z 0 and cuts through a part that straddles it).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::{Value, json};
use solidcraft_engine::Session;
use solidcraft_mcp::{Headless, Server};
static NEXT: AtomicUsize = AtomicUsize::new(0);
const SIZE: (usize, usize) = (160, 100);
fn camera() -> Value {
json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100})
}
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir).unwrap();
Self(dir)
}
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
/// saved in the design, plus `more` steps.
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
let mut steps = vec![
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
];
steps.extend(more.iter().cloned());
let path = self.0.join(name);
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
path
}
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
.arg("snapshot")
.arg(script)
.arg("--out")
.arg(self.0.join(out))
.args(["--width", &w, "--height", &h])
.args(extra)
.output()
.unwrap()
}
/// The PNG `snapshot` writes.
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
let output = self.run(script, out, extra);
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
let png = std::fs::read(self.0.join(out)).unwrap();
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
png
}
/// The PNG the MCP `screenshot` tool writes for the same design.
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
let mut session = Session::default();
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
let mut server = Server::new(Box::new(Headless::new(session)));
let mut arguments = arguments;
arguments["path"] = json!(self.0.join(out).to_string_lossy());
arguments["width"] = json!(SIZE.0);
arguments["height"] = json!(SIZE.1);
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
std::fs::read(self.0.join(out)).unwrap()
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
/// not the home view the option used to be ignored for.
#[test]
fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let home = dir.snapshot(&script, "home.png", &[]);
let json = camera().to_string();
let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]);
assert_ne!(shot, home, "--camera was ignored");
assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})));
// A perspective camera too, and a different eye gives a different picture.
let mut near = camera();
near["fov"] = json!(0.6);
let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]);
assert_ne!(perspective, shot);
assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})));
}
/// `--view` also takes a view saved in the design, which is the camera it was saved with; a
/// standard view still looks as it did.
#[test]
fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]);
let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]);
assert_eq!(saved, by_camera);
assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})));
// A standard view frames the model from its direction, as before: the home camera turned.
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
let mut s = Session::default();
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
let mut cam = solidcraft_engine::view::home_camera(&s);
cam.set_view(solidcraft_engine::render::StandardView::Top);
let scene = solidcraft_engine::view::scene(&s, &cam);
assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap());
assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"})));
assert_ne!(top, saved);
let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]);
assert_eq!(iso, dir.snapshot(&script, "home.png", &[]));
assert_ne!(iso, top);
}
/// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera
/// of its snapshot; `--view` and `--camera` still decide when given.
#[test]
fn a_script_can_carry_its_camera() {
let dir = Scratch::new();
let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]);
let plain = dir.script("plain.json", &[]);
let carried = dir.snapshot(&script, "carried.png", &[]);
assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]));
assert_ne!(carried, dir.snapshot(&plain, "home.png", &[]));
let top = dir.snapshot(&script, "top.png", &["--view", "top"]);
assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"]));
}
/// The grid and the axes each go when asked, and a render without both from any side shows the
/// model alone: an empty design is then the same blank picture from every camera.
#[test]
fn the_grid_and_the_axes_can_be_left_out() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let json = camera().to_string();
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
let (all, no_grid, no_axes, bare) = (
with(&[], "all.png"),
with(&["--no-grid"], "no-grid.png"),
with(&["--no-axes"], "no-axes.png"),
with(&["--no-grid", "--no-axes"], "bare.png"),
);
let different = [&all, &no_grid, &no_axes, &bare];
for (i, a) in different.iter().enumerate() {
for b in &different[i + 1..] {
assert_ne!(a, b, "{view:?}: two pictures are the same");
}
}
// The same as the MCP's `grid` and `axes`.
let mcp = |extra: Value, out: &str| {
let mut a = match view[0] {
"--view" => json!({"view": "front"}),
_ => json!({"camera": camera()}),
};
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
dir.mcp(&script, out, a)
};
assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png"));
assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png"));
assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"));
}
let empty = dir.0.join("empty.json");
std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap();
let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]);
assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png"));
assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"]));
}
#[test]
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
let dir = Scratch::new();
let script = dir.script("part.json", &[]);
let cases: [(&[&str], &str); 7] = [
(&["--view", "Nowhere"], "Nowhere"),
(&["--view", "iso", "--camera", "{}"], "not both"),
(&["--camera", "not json"], "--camera"),
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
(&["--camera", "[1, 2, 3]"], "camera"),
];
for (extra, expect) in cases {
let output = dir.run(&script, "never.png", extra);
let stderr = String::from_utf8_lossy(&output.stderr);
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
}
}
/// The usage text says what the new options do.
#[test]
fn the_usage_names_the_camera_and_the_guides() {
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr);
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
}
}
@@ -1,88 +0,0 @@
//! Straight edges stay one edge after many cuts (#1049). The owner's design
//! (`tests/action_matrix/designs/crazy-geom-cuts.solidcraft`, saved by 0.10.0): five extrudes,
//! joins and cuts, over four sketches, three of them on faces of the body with the faces'
//! edges projected. Cuts used to leave the straight edges of the walls they sliced split at
//! vertices nothing else used, so an edge that is one straight line was drawn (and lit on hover)
//! as many pieces, and a sketch on such a face projected every piece.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_engine::doc::kernel::{FaceSurface, face_surface};
use solidcraft_engine::geom::Vec3;
fn design() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/action_matrix/designs/crazy-geom-cuts.solidcraft")
}
fn open(path: &Path) -> Session {
let mut s = Session::default();
s.execute("doc.open", &json!({"path": path.to_string_lossy()})).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
s
}
/// The design built from scratch: saved without cached geometry and opened again.
fn rebuilt() -> Session {
let s = open(&design());
let dir = std::env::temp_dir().join(format!("solidcraft-straight-edges-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("crazy-geom-cuts.json");
let mut t = s.scratch();
t.execute("file.save", &json!({"path": path.to_string_lossy(), "format": "json"})).unwrap();
let out = open(&path);
let _ = std::fs::remove_dir_all(&dir);
out
}
fn errors(s: &Session) -> Vec<(u64, String)> {
s.model.results.iter().filter_map(|r| r.error.clone().map(|e| (r.id, e))).collect()
}
fn key(v: Vec3) -> [i64; 3] {
[v.x, v.y, v.z].map(|c| (c * 1e5).round() as i64)
}
#[test]
fn straight_edges_are_one_edge_after_many_cuts() {
for (what, s) in [("as saved", open(&design())), ("built from scratch", rebuilt())] {
assert_eq!(errors(&s), vec![], "{what}: every feature evaluates");
let st = s.world_state();
let bodies: Vec<_> = st.bodies.iter().filter(|b| !b.body.is_mesh()).collect();
assert_eq!(bodies.len(), 1, "{what}: one body");
let body = &bodies[0].body;
let mesh = body.tessellate(0.01).unwrap();
// Every edge is drawn as one polyline; a straight one as one segment.
let mut at: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
for (i, e) in mesh.edges.iter().enumerate() {
let (Some(a), Some(z)) = (e.first(), e.last()) else { panic!("{what}: edge {i} is empty") };
at.entry(key(*a)).or_default().push(i);
at.entry(key(*z)).or_default().push(i);
}
let straight = |i: usize| mesh.edges.get(i).filter(|e| e.len() == 2).and_then(|e| (e[1] - e[0]).normalized());
let split: Vec<_> = at
.iter()
.filter(|(_, es)| match es.as_slice() {
[a, b] => straight(*a).zip(straight(*b)).is_some_and(|(u, w)| u.dot(w).abs() > 1. - 1e-9),
_ => false,
})
.map(|(k, _)| k.map(|c| c as f64 / 1e5))
.collect();
assert_eq!(split, Vec::<[f64; 3]>::new(), "{what}: straight edges split where nothing else meets them");
// No two faces side by side in one plane.
let plane = |f: usize| match face_surface(body, f) {
Some(FaceSurface::Plane { normal, point }) => Some((normal, normal.dot(point))),
_ => None,
};
for (i, faces) in mesh.edge_faces.iter().enumerate() {
if let [a, b] = faces.as_slice()
&& a != b
&& let (Some((na, da)), Some((nb, db))) = (plane(*a as usize), plane(*b as usize))
{
assert!(na.dot(nb) < 1. - 1e-9 || (da - db).abs() > 1e-6, "{what}: faces {a} and {b} meet at edge {i} in one plane");
}
}
assert_eq!((body.face_count(), body.edge_count(), body.vertex_count()), (35, 107, 71), "{what}: faces, edges and vertices");
}
}
-16
View File
@@ -1,16 +0,0 @@
# Build with: trunk build --release (from apps/solidcraft-web)
[build]
target = "index.html"
dist = "../../dist/web"
public_url = "./"
filehash = true
[serve]
addresses = ["127.0.0.1"]
port = 8781
# Offline use and updates: release builds get sw.js listing this build's files (xtask/src/web_offline.rs).
[[hooks]]
stage = "post_build"
command = "cargo"
command_arguments = ["xtask", "web-offline"]
-37
View File
@@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>SolidCraft</title>
<meta name="description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="SolidCraft">
<meta property="og:title" content="SolidCraft: try it in your browser">
<meta property="og:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta property="og:url" content="https://bherbruck.github.io/solidcraft/app/">
<meta property="og:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta property="og:image:width" content="1280">
<meta property="og:image:height" content="640">
<meta property="og:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SolidCraft: try it in your browser">
<meta name="twitter:description" content="Open-source parametric CAD built in Rust. Desktop, browser, and MCP for AI agents.">
<meta name="twitter:image" content="https://bherbruck.github.io/solidcraft/og.png">
<meta name="twitter:image:alt" content="SolidCraft: open-source parametric CAD built in Rust">
<link data-trunk rel="rust" data-bin="solidcraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
<link data-trunk rel="copy-file" href="../../assets/fonts/biz-udp-gothic/OFL.txt" data-target-path="fonts" />
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
</head>
<body>
<canvas id="solidcraft_canvas"></canvas>
<div id="solidcraft_loading">Loading SolidCraft&hellip;</div>
<script>
// Offline use and updates (sw.js; written into release builds by `cargo xtask web-offline`).
if ("serviceWorker" in navigator) {
addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {}));
}
</script>
</body>
</html>
-227
View File
@@ -1,227 +0,0 @@
// SolidCraft's service worker: the web app keeps working offline and updates itself.
//
// `cargo xtask web-offline` (Trunk's post_build hook) writes the build into the line below: the
// version (workspace version + a hash of every built file) and the files to keep, each with its
// SHA-256, and the same as `build.json` next to it. Files the app only fetches when it needs
// them (the CJK font) aren't kept: they come from the network. Every download is checked
// against its SHA-256: a deployment that lands mid-download (another build's `index.html`) fails
// the download instead of mixing into the kept build. A new build changes this file, so the
// browser installs the new worker, which keeps the whole new build before taking over.
//
// A launch only ever starts a build whose every file is kept, so a half-downloaded update can't
// break it. It asks for `build.json` (waiting at most NAVIGATE_TIMEOUT_MS): offline, slow or
// unchanged, the kept build starts; when a new build is deployed, it is downloaded first
// (waiting at most UPDATE_TIMEOUT_MS; the download goes on in the background after that) and
// started. When the site has no `build.json` (the app was removed), launches go to the network,
// the kept build answering when that fails. Each page then gets its files from the build it
// started (remembered in PAGES), never from another build with a file of the same name.
//
// A cache is only deleted when nothing uses it: a download holds its lock (Web Locks, shared,
// in any tab or worker; the browser releases it when the download ends or its worker stops), and
// an open page uses the build it started. Then unfinished downloads are dropped, and so are
// builds replaced by a newer worker (RETIRED). The origin is shared with other apps (github.io)
// and other copies of this one (the release zip works from any path): only caches named after
// this worker's path (CACHE_PREFIX…, PAGES) are ever touched.
//
// Unbuilt (VERSION "dev", as debug builds get it), the worker retires: it drops what a release
// build kept and unregisters, so `trunk serve` after `trunk serve --release --no-autoreload`
// shows the development build. Deploying this file unbuilt switches the worker off for everyone.
/* @build */ const VERSION = "dev", FILES = [];
const RETIRE = VERSION === "dev";
const PATH = new URL(self.registration.scope).pathname;
const CACHE_PREFIX = `solidcraft-app ${PATH} `;
const CACHE = CACHE_PREFIX + VERSION;
/** Which build each open page started: page id → cache name (or NETWORK). */
const PAGES = `solidcraft-app-pages ${PATH}`;
/** A page that came from the network gets its files from there too. */
const NETWORK = "network";
const NAVIGATE_TIMEOUT_MS = 4000;
const UPDATE_TIMEOUT_MS = 20000;
/** The entry that marks a cache as holding its whole build. */
const COMPLETE = "build-complete";
/** The entry that marks a build replaced by a newer worker. */
const RETIRED = "build-retired";
/** A page remembered this recently may still be loading (not yet listed among the clients). */
const PAGE_LOADING_MS = 30 * 1000;
const url = (file) => new URL(file, self.registration.scope).href;
const has = (name, entry) => caches.match(url(entry), { cacheName: name }).then(Boolean);
/** Run `fn` while using the cache `name`: it isn't deleted meanwhile. */
const using = (name, fn) => navigator.locks.request(name, { mode: "shared" }, fn);
/** Delete the cache `name` if nothing is using it and `unwanted()` (asked once no one can start to) agrees. */
const drop = (name, unwanted) =>
navigator.locks.request(name, { ifAvailable: true }, async (free) => free && (await unwanted()) && caches.delete(name));
const hex = (buffer) => Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
/**
* Keep every file of a build (an earlier partial download is resumed); resolves to its cache's
* name. The first file that fails cancels the others.
*/
function keep(version, files) {
const name = CACHE_PREFIX + version;
return using(name, async () => {
const cache = await caches.open(name);
await cache.delete(url(RETIRED)); // Back in use (a rollback).
if (await has(name, COMPLETE)) return name;
const cancel = new AbortController();
const results = await Promise.allSettled(
files.map(async ({ file, sha256 }) => {
try {
if (await cache.match(url(file))) return;
// `no-cache`: revalidate with the server, so a stale HTTP-cached page never mixes builds.
const response = await fetch(new Request(url(file), { cache: "no-cache" }), { signal: cancel.signal });
if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`);
const body = await response.arrayBuffer();
if (hex(await crypto.subtle.digest("SHA-256", body)) !== sha256) throw new Error(`${file}: not this build's`);
await cache.put(url(file), new Response(body, { headers: response.headers }));
} catch (error) {
cancel.abort();
throw error;
}
}),
);
const failed = results.find((r) => r.status === "rejected");
if (failed) throw failed.reason;
await cache.put(url(COMPLETE), new Response(version));
return name;
});
}
const pageEntry = (id) => url(`~page/${encodeURIComponent(id)}`);
/** Remember which build a page started. */
async function started(id, build) {
if (!id) return;
const record = JSON.stringify({ build, at: Date.now() });
await (await caches.open(PAGES)).put(pageEntry(id), new Response(record));
}
/** The build a page started; a page started before it was remembered gets this worker's. */
async function startedBy(id) {
const response = id ? await caches.match(pageEntry(id), { cacheName: PAGES }) : null;
return response ? (await response.json()).build : CACHE;
}
/** The builds open pages use (pages closed since are forgotten). */
async function inUse() {
const open = new Set((await self.clients.matchAll({ includeUncontrolled: true })).map((c) => c.id));
const pages = await caches.open(PAGES);
const builds = new Set();
for (const request of await pages.keys()) {
const id = decodeURIComponent(request.url.split("~page/")[1] ?? "");
const record = await pages.match(request).then((r) => r?.json()).catch(() => null);
if (record && (open.has(id) || Date.now() - record.at < PAGE_LOADING_MS)) builds.add(record.build);
else await pages.delete(request);
}
return builds;
}
/** Drop unfinished downloads nothing is using, and replaced builds no open page uses. */
async function sweep() {
for (const name of await caches.keys()) {
if (!name.startsWith(CACHE_PREFIX) || name === CACHE) continue;
await drop(name, async () => !(await has(name, COMPLETE)) || ((await has(name, RETIRED)) && !(await inUse()).has(name)));
}
}
self.addEventListener("install", (event) => {
// Clean up first: a page open for days sees updates (and failed ones) without any launch.
const install = () => sweep().catch(() => {}).then(() => keep(VERSION, FILES));
event.waitUntil(RETIRE ? self.skipWaiting() : install().then(() => self.skipWaiting()));
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
if (RETIRE) {
for (const name of await caches.keys()) if (name.startsWith(CACHE_PREFIX) || name === PAGES) await caches.delete(name);
return self.registration.unregister();
}
for (const name of await caches.keys()) {
if (name.startsWith(CACHE_PREFIX) && name !== CACHE) await (await caches.open(name)).put(url(RETIRED), new Response(""));
}
await sweep();
await self.clients.claim();
})(),
);
});
/** The promise's value, or null when it fails or takes longer than `ms`. */
function within(ms, promise) {
return Promise.race([promise.catch(() => null), new Promise((resolve) => setTimeout(resolve, ms, null))]);
}
/** No app is deployed here any more (the server says `build.json` doesn't exist). */
const GONE = "gone";
/** The deployed build's version and files, GONE, or null (unknown: offline, failing, invalid). */
async function deployed() {
const response = await fetch(url("build.json"), { cache: "no-cache" });
if (response.status === 404 || response.status === 410) return GONE;
const build = response.ok ? await response.json() : null;
const valid = build && typeof build.version === "string" && Array.isArray(build.files);
return valid && build.files.every((f) => typeof f.file === "string" && typeof f.sha256 === "string") ? build : null;
}
/** Keep the worker alive for `promise` (allowed while the launch is being answered). */
function extend(event, promise) {
try {
event.waitUntil(promise.catch(() => {}));
} catch {
// The event has ended; the work goes on while the worker runs.
}
}
/** A launch: the start page of a complete build (whatever query the URL has: `?sample`), and that build. */
async function launch(event) {
const kept = await caches.match(url("index.html"), { cacheName: CACHE });
if (!kept) return [await fetch(event.request), NETWORK]; // Nothing kept yet (the first visit).
extend(event, sweep());
const build = await within(NAVIGATE_TIMEOUT_MS, deployed());
if (build === GONE) {
const page = await fetch(event.request).catch(() => null);
return page && page.ok ? [page, NETWORK] : [kept, CACHE];
}
if (!build || build.version === VERSION) return [kept, CACHE];
const download = keep(build.version, build.files);
extend(event, download);
const name = await within(UPDATE_TIMEOUT_MS, download);
const page = name && (await caches.match(url("index.html"), { cacheName: name }));
return page ? [page, name] : [kept, CACHE];
}
/**
* A launch that fails unexpectedly still starts the kept build (or asks the network). A newer
* worker only takes over once the launch has ended, by when its page is remembered (in use).
*/
async function launchOrKept(event) {
let page, build;
try {
[page, build] = await launch(event);
} catch {
page = await caches.match(url("index.html"), { cacheName: CACHE }).catch(() => null);
build = page ? CACHE : NETWORK;
page = page || (await fetch(event.request));
}
await started(event.resultingClientId, build).catch(() => {});
return page;
}
/** A page's files come from the build it started, else the network. */
async function asset(event) {
const build = await startedBy(event.clientId).catch(() => CACHE);
const kept = build === NETWORK ? null : await caches.match(event.request, { cacheName: build, ignoreSearch: true });
return kept || fetch(event.request);
}
self.addEventListener("fetch", (event) => {
const request = event.request;
if (RETIRE || request.method !== "GET" || new URL(request.url).origin !== self.location.origin) return;
const path = request.url.split(/[?#]/)[0];
const start = request.mode === "navigate" && (path === url("") || path === url("index.html"));
event.respondWith(start ? launchOrKept(event) : asset(event));
});
-69
View File
@@ -1,69 +0,0 @@
[package]
name = "solidcraft"
description = "SolidCraft desktop app"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
default-run = "solidcraft"
# The workspace lints, except unsafe_code: denied, not forbidden, so that src/open_documents.rs
# (macOS file-open events) and src/native_crash.rs (Windows exception filter) can allow it: neither
# has a 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"
[dependencies]
solidcraft-engine = { workspace = true }
solidcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
# Checks eframe's settings file (app.ron) before eframe reads it (src/startup.rs).
ron = "0.12"
# Waits for wgpu's adapter list when choosing the graphics backend before the window opens.
pollster = { workspace = true }
getrandom = "0.3"
# The update check (docs/updates.md): HTTPS with rustls, certificates from the operating system.
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "platform-verifier"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] }
[target.'cfg(any(target_os = "windows", target_os = "linux"))'.dependencies]
quick-xml = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
# Native crash capture (src/native_crash.rs): the exception filter, the faulting module, a minidump.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Diagnostics_Debug",
"Win32_System_IO",
"Win32_System_Kernel",
"Win32_System_LibraryLoader",
"Win32_System_Memory",
"Win32_System_Threading",
] }
[target.'cfg(target_os = "macos")'.dependencies]
# Files opened from Finder and the Dock (src/open_documents.rs); all already come with eframe and rfd.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] }
block2 = "0.6"
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[build-dependencies]
embed-resource = "3.0.11"
-17
View File
@@ -1,17 +0,0 @@
#![forbid(unsafe_code)]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let icon = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/icons/app/windows/exports/solidcraft-executable.ico");
println!("cargo:rerun-if-changed={}", icon.display());
println!("cargo:rerun-if-changed=build.rs");
if std::env::var("CARGO_CFG_TARGET_OS")? != "windows" {
return Ok(());
}
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").ok_or("missing OUT_DIR")?);
let copy = out.join("solidcraft.ico");
std::fs::copy(icon, &copy)?;
let rc = out.join("solidcraft.rc");
std::fs::write(&rc, format!("1 ICON \"{}\"\n", copy.display().to_string().replace('\\', "/")))?;
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
Ok(())
}
-297
View File
@@ -1,297 +0,0 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! Agents and tests drive the app through it (adapted from CADCraft, MIT OR Apache-2.0).
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use solidcraft_engine::MAX_LINE;
use solidcraft_engine::control_auth;
use solidcraft_ui_egui::ControlRequest;
/// Connections served at once, so a flood must not be able to spawn threads without end.
const MAX_CONNECTIONS: usize = 16;
/// Lines that are not JSON before the connection is closed, so garbage cannot loop forever.
const MAX_BAD_LINES: usize = 8;
static OPEN: AtomicUsize = AtomicUsize::new(0);
/// The token file this launch wrote, removed when the app ends.
static TOKEN_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// A new random token: 32 bytes from the operating system, as hex.
fn new_token() -> Option<String> {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).ok()?;
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
/// Remove this launch's token file (at exit).
pub fn remove_token_file() {
if let Some(p) = TOKEN_FILE.get() {
let _ = std::fs::remove_file(p);
}
}
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
// Every connection must show the token before anything runs (docs/control-protocol.md).
let Some(token) = control_auth::env_token().or_else(new_token) else {
eprintln!("solidcraft: control server not started: no random token available");
return rx;
};
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
return rx;
}
};
match control_auth::token_path(port) {
Some(path) => match control_auth::write_token_file(&path, &token) {
Ok(()) => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port} (token in {})", path.display());
let _ = TOKEN_FILE.set(path);
}
Err(e) => eprintln!(
"solidcraft: control server listening on 127.0.0.1:{port}, but its token file {} could not be written ({e}); \
clients need SOLIDCRAFT_CONTROL_TOKEN",
path.display()
),
},
None => {
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}; no data folder for its token, clients need SOLIDCRAFT_CONTROL_TOKEN")
}
}
let token = std::sync::Arc::new(token);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
OPEN.fetch_sub(1, Ordering::Relaxed);
let mut refused = stream;
let _ = writeln!(refused, "{}", json!({"ok": false, "error": "too many connections"}));
continue;
}
let tx = tx.clone();
let ctx = ctx.clone();
let token = token.clone();
std::thread::spawn(move || {
serve(stream, &token, tx, ctx);
OPEN.fetch_sub(1, Ordering::Relaxed);
});
}
});
rx
}
/// One line without its newline, or `None` at the end of the stream. A client that sends
/// gigabytes with no newline would otherwise make this thread allocate until the process is
/// killed, so the line is refused as soon as it passes `max`.
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
let mut line = Vec::new();
loop {
let (done, used) = {
let available = r.fill_buf()?;
if available.is_empty() {
return Ok((!line.is_empty()).then_some(line));
}
match available.iter().position(|b| *b == b'\n') {
Some(i) => {
line.extend_from_slice(available.get(..i).unwrap_or(&[]));
(true, i + 1)
}
None => {
line.extend_from_slice(available);
(false, available.len())
}
}
};
r.consume(used);
if done {
return Ok(Some(line));
}
if line.len() > max {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
}
}
}
/// The reply to a connection that didn't show the token; the connection is then closed.
fn unauthorized() -> Value {
json!({"ok": false, "error": "unauthorized: send the control token as \"token\" (see docs/control-protocol.md)"})
}
/// Refuse the connection: the reply, then the end of what we send, then what the client had
/// already sent read and dropped (a little, briefly). Closing a socket with unread input resets
/// the connection, and a reset can reach the client before it has read the refusal.
fn refuse(out: &mut TcpStream, reader: &mut BufReader<TcpStream>) {
use std::io::Read;
let _ = writeln!(out, "{}", unauthorized());
let _ = out.shutdown(std::net::Shutdown::Write);
let _ = reader.get_ref().set_read_timeout(Some(Duration::from_millis(500)));
let _ = std::io::copy(&mut reader.by_ref().take(64 * 1024), &mut std::io::sink());
}
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut bad = 0usize;
let mut authenticated = false;
loop {
let line = match read_line(&mut reader, MAX_LINE) {
Ok(Some(l)) => String::from_utf8_lossy(&l).into_owned(),
Ok(None) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e.to_string()}));
break;
}
};
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(msg) => {
// The first request must carry the token; later ones on the same connection may.
if !authenticated {
match msg.get("token").and_then(Value::as_str) {
Some(t) if control_auth::tokens_match(t, token) => authenticated = true,
_ => {
refuse(&mut out, &mut reader);
break;
}
}
}
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// A batch replaces many short requests: the whole of it may take long (#743).
let wait = Duration::from_secs(if method == "engine.batch" { 3600 } else { 60 });
let (req, rrx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let mut r = rrx.recv_timeout(wait).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
if let Some(o) = r.as_object_mut() {
o.insert("id".into(), id);
}
r
}
Err(_) if !authenticated => {
refuse(&mut out, &mut reader);
break;
}
Err(e) => {
bad += 1;
json!({"ok": false, "error": format!("bad JSON: {e}")})
}
};
if writeln!(out, "{reply}").is_err() {
break;
}
if bad >= MAX_BAD_LINES {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// #205: a connection runs nothing until a request carries the launch's token; a wrong or
/// missing token, or garbage, is refused and the connection closed. Once shown, the token
/// need not be repeated on that connection.
#[test]
fn requests_run_only_after_the_token() {
use std::io::Read;
let token = "0123456789abcdef0123456789abcdef";
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
let ran = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = ran.clone();
std::thread::spawn(move || {
for req in rx {
seen.lock().unwrap().push(req.method.clone());
let _ = req.reply.send(json!({"ok": true, "result": null}));
}
});
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
std::thread::spawn(move || serve(stream, token, tx, egui::Context::default()));
}
});
let session = |lines: &[String]| {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
for l in lines {
let _ = writeln!(s, "{l}");
}
let _ = s.shutdown(std::net::Shutdown::Write);
let mut text = String::new();
let _ = s.read_to_string(&mut text);
text.lines().map(|l| serde_json::from_str::<Value>(l).unwrap()).collect::<Vec<_>>()
};
for first in [
json!({"id": 1, "method": "app.quit", "params": {"force": true}}).to_string(),
json!({"id": 1, "method": "app.quit", "token": "0123456789abcdef0123456789abcdee"}).to_string(),
json!({"id": 1, "method": "app.quit", "token": "0123"}).to_string(),
json!({"id": 1, "method": "app.quit", "token": 7}).to_string(),
"not json".to_string(),
] {
let replies = session(&[first.clone(), json!({"id": 2, "method": "app.quit", "token": token}).to_string()]);
assert_eq!(replies.len(), 1, "{first}: closed after refusing: {replies:?}");
assert!(replies[0]["error"].as_str().is_some_and(|e| e.starts_with("unauthorized")), "{first}: {replies:?}");
}
assert!(ran.lock().unwrap().is_empty(), "nothing ran without the token");
let replies = session(&[
json!({"id": 1, "method": "ui.inspect", "token": token}).to_string(),
json!({"id": 2, "method": "engine.commands"}).to_string(),
]);
assert_eq!(replies.len(), 2, "{replies:?}");
assert!(replies.iter().all(|r| r["ok"] == true), "{replies:?}");
assert_eq!(*ran.lock().unwrap(), ["ui.inspect", "engine.commands"]);
}
/// A request longer than the cap is refused instead of allocated.
#[test]
fn lines_past_the_cap_are_refused() {
let flood = vec![b'A'; MAX_LINE + 2];
let mut r = BufReader::new(flood.as_slice());
let e = read_line(&mut r, MAX_LINE).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
assert!(e.to_string().contains("request too large"), "{e}");
}
/// Ordinary framing: lines split on newlines, blank lines kept, no trailing newline needed.
#[test]
fn lines_split_on_newlines() {
let text = b"{\"a\":1}\n\n{\"b\":2}\nlast".to_vec();
let mut r = BufReader::new(text.as_slice());
let mut got = Vec::new();
while let Some(line) = read_line(&mut r, MAX_LINE).unwrap() {
got.push(String::from_utf8(line).unwrap());
}
assert_eq!(got, vec!["{\"a\":1}", "", "{\"b\":2}", "last"]);
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
}
/// A line that spans several reads of the underlying stream still arrives whole.
#[test]
fn lines_longer_than_one_buffer_arrive_whole() {
let long = "x".repeat(200_000);
let text = format!("{long}\n{long}\n").into_bytes();
let mut r = BufReader::with_capacity(64, text.as_slice());
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
assert_eq!(read_line(&mut r, MAX_LINE).unwrap().unwrap().len(), long.len());
assert!(read_line(&mut r, MAX_LINE).unwrap().is_none());
// The same line against a smaller cap is refused.
let mut r = BufReader::with_capacity(64, text.as_slice());
assert!(read_line(&mut r, 1000).is_err());
}
}
-698
View File
@@ -1,698 +0,0 @@
//! SolidCraft desktop app.
//!
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]`
//!
//! `--safe-mode` (or `SOLIDCRAFT_SAFE_MODE=1`) starts with default settings, no recovery offer and
//! the software renderer; see `startup` and docs/book/src/guide/troubleshooting.md.
//!
//! A `.step`/`.stp` file opens as a new design holding the file's bodies (an Import feature); a
//! `.3mf`/`.stl` file as a new design holding mesh bodies.
//!
//! `--control <port>` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
//! See `solidcraft_ui_egui::control` and docs/control-protocol.md.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![deny(unsafe_code)]
#[cfg(not(target_os = "macos"))]
#[path = "../../../assets/icons/app/shared/source/app_icon.rs"]
mod app_icon;
mod control_server;
#[cfg(target_os = "windows")]
mod native_crash;
#[cfg(target_os = "macos")]
mod open_documents;
#[cfg(any(target_os = "windows", target_os = "linux"))]
mod space_mouse;
mod startup;
use solidcraft_engine::{Session, io};
use solidcraft_ui_egui::{Services, SolveApp};
struct App {
solve: SolveApp,
/// The settings folder whose start marker the first frame clears (`startup::started`).
starting: Option<std::path::PathBuf>,
/// Frames begun; the second one means the first was drawn (startup is over).
frames: u32,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse: space_mouse::SpaceMouse,
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if self.frames < 2 {
self.frames += 1;
// A second frame right away, which means the first was presented: the app is up.
ctx.request_repaint();
if self.frames == 2 {
solidcraft_engine::crash::phase(solidcraft_engine::crash::RUNNING);
if let Some(dir) = self.starting.take() {
startup::started(&dir);
}
}
}
// The design on screen is the one whose commands a crash report replays.
self.solve.session.crash_journal = solidcraft_engine::crash::enabled();
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
}
#[cfg(target_os = "macos")]
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux"))]
if !self.solve.file_picker_busy() {
self.space_mouse.tick(&mut self.solve, ctx);
}
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.solve.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.solve.ui(ui);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.solve.prefs());
}
fn on_exit(&mut self) {
solidcraft_ui_egui::recovery_ui::close(&mut self.solve);
solidcraft_engine::crash::end_session();
// An update asked to restart into the new version or to run its installer.
if let Some((program, args)) = solidcraft_ui_egui::update::take_launch(&mut self.solve)
&& let Err(e) = std::process::Command::new(&program).args(&args).spawn()
{
log::error!("update: starting {}: {e}", program.display());
}
}
}
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
let program_parent = parent.clone();
Services {
pick_open: Some(Box::new(move || {
let future = open_parent
.clone()
.add_filter(
"Designs, STEP, IGES and meshes",
&[
io::DESIGN_EXT,
io::LEGACY_DESIGN_EXT,
"json",
"step",
"stp",
"STEP",
"STP",
"igs",
"iges",
"IGS",
"IGES",
"3mf",
"3MF",
"stl",
"STL",
],
)
.add_filter("SolidCraft design (container or JSON)", &[io::DESIGN_EXT, io::LEGACY_DESIGN_EXT, "json"])
.add_filter("STEP", &["step", "stp", "STEP", "STP"])
.add_filter("IGES", &["igs", "iges", "IGS", "IGES"])
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_program: Some(Box::new(move || {
let dialog = program_parent.clone().set_title("Choose your slicer");
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
let future = dialog.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let dialog = parent.clone().set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
if name.ends_with(".json") {
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
} else {
dialog.add_filter("SolidCraft container", &[io::DESIGN_EXT]).add_filter("Plain JSON design", &["json"])
}
} else {
dialog.add_filter("File", exts)
};
let future = dialog.save_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
http_download: Some(std::sync::Arc::new(http_download)),
show_in_folder: Some(Box::new(|path| {
let folder = std::path::Path::new(path).parent().unwrap_or_else(|| std::path::Path::new("."));
let mut command = std::process::Command::new(if cfg!(target_os = "windows") {
"explorer"
} else if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
});
command.arg(folder);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000);
}
let _ = command.spawn();
})),
}
}
/// HTTPS GET for the update check (docs/updates.md): certificates from the operating system, a
/// 15 s limit, at most 1 MB, and no headers beyond what GitHub requires (a User-Agent without
/// the version or anything about this machine, and the API's media type).
fn http_get(url: &str) -> Result<Vec<u8>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
let mut response = http_call(url, std::time::Duration::from_secs(15))?;
response.body_mut().with_config().limit(1 << 20).read_to_vec().map_err(|e| FetchError::Failed(e.to_string()))
}
/// HTTPS download of an update package to `to` (docs/updates.md), reporting progress; stops when
/// `progress` returns false. The caller verifies the file.
fn http_download(url: &str, to: &std::path::Path, progress: &dyn Fn(u64, Option<u64>) -> bool) -> Result<(), solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::io::{Read, Write};
let fail = |e: String| FetchError::Failed(e);
let mut response = http_call(url, std::time::Duration::from_secs(30 * 60))?;
let total = response.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
let mut body = response.body_mut().with_config().limit(solidcraft_ui_egui::update::install::MAX_PACKAGE).reader();
let mut file = std::fs::File::create(to).map_err(|e| fail(format!("{}: {e}", to.display())))?;
let mut buf = vec![0u8; 1 << 16];
let mut done = 0u64;
loop {
let n = body.read(&mut buf).map_err(|e| fail(e.to_string()))?;
if n == 0 {
break;
}
file.write_all(buf.get(..n).unwrap_or_default()).map_err(|e| fail(format!("{}: {e}", to.display())))?;
done += n as u64;
if !progress(done, total) {
return Err(fail("cancelled".into()));
}
}
file.sync_all().map_err(|e| fail(format!("{}: {e}", to.display())))?;
Ok(())
}
/// One GET to GitHub: a 200 answer, or why not (rate limits included).
fn http_call(url: &str, timeout: std::time::Duration) -> Result<ureq::http::Response<ureq::Body>, solidcraft_ui_egui::update::FetchError> {
use solidcraft_ui_egui::update::FetchError;
use std::time::Duration;
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.unversioned_rustls_crypto_provider(std::sync::Arc::new(rustls::crypto::ring::default_provider()))
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(tls)
.https_only(true)
.http_status_as_error(false)
.timeout_global(Some(timeout))
.timeout_connect(Some(Duration::from_secs(5)))
.user_agent("SolidCraft-update-check")
.max_redirects(3)
.build()
.into();
let response = agent
.get(url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.call()
.map_err(|e| FetchError::Failed(e.to_string()))?;
let status = response.status().as_u16();
let header = |k: &str| response.headers().get(k).and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
if status == 429 || (status == 403 && (header("x-ratelimit-remaining") == Some(0) || header("retry-after").is_some())) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
let retry_after_s = header("retry-after").or_else(|| header("x-ratelimit-reset").map(|reset| reset.saturating_sub(now))).unwrap_or(3600);
return Err(FetchError::RateLimited { retry_after_s });
}
if status != 200 {
return Err(FetchError::Failed(format!("GitHub answered HTTP {status}")));
}
Ok(response)
}
/// The per-user settings folder: eframe's storage (`app.ron`) and the anti-aliasing preference.
fn settings_dir() -> Option<std::path::PathBuf> {
eframe::storage_dir("SolidCraft")
}
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
/// window opens).
fn graphics_path() -> Option<std::path::PathBuf> {
settings_dir().map(|d| d.join("graphics.json"))
}
/// Compatibility shim (docs/compatibility.md): on the first launch after the rename, copy the
/// settings of earlier releases into the new folder. They kept `graphics.json` (and, on Linux
/// and macOS, the recovery folder) under the old product name, and eframe's `app.ron` under the
/// window's old app id. In the Flatpak sandbox, releases before 0.8 kept everything under the
/// old app id's `~/.var/app` folder; that copy comes first, as it is the newest. The old folders
/// are never changed.
fn adopt_legacy_settings() {
let (Some(current), Some(legacy)) = (settings_dir(), eframe::storage_dir("SolveCraft")) else { return };
let legacy_eframe = eframe::storage_dir(solidcraft_engine::io::LEGACY_APP_ID);
let old_flatpak = solidcraft_engine::io::legacy_flatpak_path(&current);
if let Err(e) = adopt_settings(&current, old_flatpak.as_deref(), &legacy, legacy_eframe.as_deref()) {
log::warn!("settings: could not copy the previous release's settings into {}: {e}", current.display());
}
}
fn adopt_settings(
current: &std::path::Path,
old_flatpak: Option<&std::path::Path>,
legacy: &std::path::Path,
legacy_eframe: Option<&std::path::Path>,
) -> std::io::Result<()> {
let fresh = !current.exists();
if let Some(old) = old_flatpak {
solidcraft_engine::io::adopt_legacy_dir(current, old)?;
}
solidcraft_engine::io::adopt_legacy_dir(current, legacy)?;
if let Some(old) = legacy_eframe.map(|d| d.join("app.ron")).filter(|p| fresh && p.is_file()) {
std::fs::create_dir_all(current)?;
let new = current.join("app.ron");
if !new.exists() {
std::fs::copy(old, new)?;
}
}
Ok(())
}
/// `graphics.json`, read before the window opens.
fn graphics() -> Option<serde_json::Value> {
graphics_path().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
}
/// The graphics backend preference (`graphics.json`'s `backend`: `auto`, `dx12`, `vulkan`, `gl`).
fn backend_preference() -> Option<String> {
graphics().and_then(|v| v.get("backend").and_then(serde_json::Value::as_str).map(str::to_string))
}
/// Anti-aliasing samples from Preferences (4 when unset).
fn msaa() -> u16 {
graphics()
.and_then(|v| v.get("msaa").and_then(serde_json::Value::as_u64))
.and_then(|m| u16::try_from(m).ok())
.filter(|m| [1, 2, 4, 8].contains(m))
.unwrap_or(MSAA)
}
/// Windows and Linux: no OS title bar; the application bar is the title bar
/// (`solidcraft_ui_egui::titlebar`). macOS keeps its traffic lights over the integrated bar.
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
const DEPTH_BITS: u8 = 24;
/// The section cap marks where it shows in the stencil.
const STENCIL_BITS: u8 = 8;
const MSAA: u16 = 4;
fn app_viewport() -> Result<egui::ViewportBuilder, eframe::Error> {
// Let macOS use the bundle's layered icon; eframe's default bitmap would replace it.
#[cfg(target_os = "macos")]
let icon = egui::IconData::default();
#[cfg(not(target_os = "macos"))]
let icon =
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(icon)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!CUSTOM_TITLEBAR)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID))
}
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
const APP_ID: &str = solidcraft_engine::io::APP_ID;
fn main() -> eframe::Result {
adopt_legacy_settings();
let mut safe_mode = startup::env_on(std::env::var(startup::SAFE_MODE_ENV).ok().as_deref());
let mut control_port: Option<u16> = solidcraft_engine::geom::env::var("CONTROL_PORT").and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--sample" => sample = true,
"--safe-mode" => safe_mode = true,
"--version" | "-V" => {
println!("solidcraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--crash-log" => {
match solidcraft_engine::crash::default_dir() {
Some(dir) => {
println!("crash reports: {}", dir.display());
for r in solidcraft_engine::crash::list(&dir).iter().take(5) {
println!(" {} {} {}", r.id, r.kind, r.message);
}
println!("more: solidcraft-cli crashes");
}
None => println!("no crash folder on this system"),
}
return Ok(());
}
"--help" | "-h" => {
println!("usage: solidcraft [--control PORT] [--sample] [--crash-log] [--safe-mode] [design.solidcraft | part.step]");
return Ok(());
}
_ => files.push(a),
}
}
// Settings that don't read are moved aside; launches that never got a window are counted,
// and after a few in a row this one starts in safe mode (docs/book/src/guide/troubleshooting.md).
let settings = settings_dir();
let mut notes: Vec<String> = Vec::new();
let mut failed_starts = 0;
if let Some(dir) = &settings {
notes.extend(startup::check_settings(dir, &[solidcraft_ui_egui::prefs::STORAGE_KEY, solidcraft_ui_egui::prefs::LEGACY_STORAGE_KEY]));
failed_starts = startup::begin(dir);
}
if failed_starts >= startup::FAILED_STARTS_FOR_SAFE_MODE && !safe_mode {
safe_mode = true;
notes.push(format!("The last {failed_starts} launches stopped before the window opened, so this one is in safe mode."));
}
let cpu_renderer = safe_mode || startup::cpu_renderer_env(std::env::var(startup::RENDERER_ENV).ok().as_deref());
if safe_mode {
notes.push("Safe mode: default settings (yours are kept for the next normal start), no recovery offer, software rendering.".into());
}
let samples = if safe_mode { MSAA } else { msaa() };
// Crashes leave a report in the crash folder (solidcraft_engine::crash, docs/crash-reports.md).
let crashed = solidcraft_engine::crash::default_dir()
.map(|dir| {
let build = solidcraft_engine::crash::Build {
version: solidcraft_ui_egui::help::VERSION,
commit: solidcraft_ui_egui::help::COMMIT,
built: solidcraft_ui_egui::help::BUILD_DATE,
};
solidcraft_engine::crash::install(dir, build)
})
.unwrap_or_default();
// A crash in native code (a graphics driver) says where it happened: the exception and the
// module on Windows, and on every system the startup step the app was in.
#[cfg(target_os = "windows")]
native_crash::install();
eframe::egui_wgpu::set_startup_hook(startup_step);
solidcraft_engine::crash::phase("window");
let options = eframe::NativeOptions {
viewport: app_viewport()?,
depth_buffer: DEPTH_BITS,
stencil_buffer: STENCIL_BITS,
multisampling: samples,
// Kept with the other settings, not in a folder named after the app id. Safe mode reads
// and writes a scratch copy, leaving the user's settings as they were.
persistence_path: if safe_mode {
let scratch = std::env::temp_dir().join(format!("solidcraft-safe-mode-{}.ron", std::process::id()));
let _ = std::fs::remove_file(&scratch);
Some(scratch)
} else {
settings.as_ref().map(|d| d.join("app.ron"))
},
persist_window: !safe_mode,
wgpu_options: wgpu_options(cpu_renderer, if safe_mode { None } else { backend_preference() }.as_deref()),
..Default::default()
};
#[cfg(all(unix, not(target_os = "macos")))]
let mut options = options;
#[cfg(all(unix, not(target_os = "macos")))]
if prefer_x11(
std::env::var_os("DISPLAY").is_some(),
std::env::var_os("WAYLAND_DISPLAY").is_some(),
std::env::var("XDG_CURRENT_DESKTOP").ok().as_deref(),
solidcraft_engine::geom::env::var("WAYLAND").as_deref(),
) {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
// Files opened from Finder and the Dock arrive as events, not arguments.
#[cfg(target_os = "macos")]
open_documents::install();
let created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_app = created.clone();
let starting = settings.clone();
let result = eframe::run_native(
"SolidCraft",
options,
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_engine::crash::phase("app setup");
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services(cc));
app.apply_off_thread = Some(solidcraft_ui_egui::dialogs::APPLY_AT_ONCE);
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))).filter(|_| !safe_mode) {
app.load_prefs(&p);
}
if let Some(rs) = &cc.wgpu_render_state {
eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples);
solidcraft_engine::crash::phase("viewport pipelines (shader compile)");
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
// The graphics driver is loaded now; it may have put its own exception filter
// in front of ours.
#[cfg(target_os = "windows")]
native_crash::reinstall();
// A saved anti-aliasing the device can't do: back to the default, with a notice.
solidcraft_ui_egui::prefs::check_msaa(&mut app, u32::from(samples));
let info = rs.adapter.get_info();
solidcraft_engine::crash::set_environment(&format!(
"Renderer: {:?} {} ({}), MSAA {}x",
info.backend, info.name, info.driver, rs.msaa_samples
));
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
}
if sample {
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
app.set_status(format!("sample: {e}"), true);
}
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
app.session.undo.clear();
}
// A launch from Finder: its files arrived before the window.
#[cfg(target_os = "macos")]
let files = {
open_documents::set_ui(&cc.egui_ctx);
[files, open_documents::take()].concat()
};
for f in &files {
app.open_path_async(f);
}
// No file and no sample: the empty design takes Units of new designs (#1205).
// An opened file and the sample part keep the units they were saved or built with.
if files.is_empty() && !sample {
solidcraft_ui_egui::prefs::apply_new_design(&mut app);
}
// No design given: the start page (not when driven over the control channel, whose
// clients expect the design view; `ui.home` shows it there).
if files.is_empty() && !sample && control_port.is_none() {
let rev = app.session.revision;
app.home.show(rev);
}
// Autosave, and offer what crashed sessions left behind.
if let Some(dir) = solidcraft_engine::recovery::default_dir() {
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
solidcraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
// Safe mode keeps autosaving but offers nothing: the entries wait for a normal start.
if safe_mode {
app.recoverable.clear();
}
}
// Tell the user about crashes since the last start (after the recovery offer is set up).
solidcraft_ui_egui::crash_ui::announce(&mut app, &crashed);
if !notes.is_empty() {
app.set_status(notes.join(" "), true);
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
solidcraft_engine::crash::phase("first frame");
Ok(Box::new(App {
solve: app,
starting,
frames: 0,
#[cfg(any(target_os = "windows", target_os = "linux"))]
space_mouse,
}))
}),
);
control_server::remove_token_file();
match result {
Err(e) if !created.load(std::sync::atomic::Ordering::SeqCst) => no_window(e, cpu_renderer, settings.as_deref()),
r => r,
}
}
/// egui-wgpu is about to start a step of getting the graphics device and the window's surface up:
/// the crash journal keeps it, and the adapter once it is picked.
fn startup_step(step: &'static str, detail: &str) {
if step == eframe::egui_wgpu::STEP_ADAPTER_CHOSEN {
solidcraft_engine::crash::set_environment(&format!("Renderer: {detail}"));
}
solidcraft_engine::crash::phase(step);
}
/// The renderer settings: with `cpu`, the software adapter (WARP, lavapipe, llvmpipe). The
/// backends come from `startup::choose_backends` (on Windows D3D12 first, and only the backend
/// in use is started); `preference` is `graphics.json`'s `backend`.
fn wgpu_options(cpu: bool, preference: Option<&str>) -> eframe::egui_wgpu::WgpuConfiguration {
let mut options = eframe::egui_wgpu::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
let env_set = std::env::var(startup::WGPU_BACKEND_ENV).is_ok_and(|v| !v.trim().is_empty());
if let Some(backends) = startup::choose_backends(cfg!(target_os = "windows"), env_set, cpu, preference, startup::has_hardware_adapter) {
eprintln!("SolidCraft graphics: starting {backends:?}");
setup.instance_descriptor.backends = backends;
}
if cpu {
setup.native_adapter_selector = Some(std::sync::Arc::new(startup::pick_cpu_adapter));
}
}
options
}
/// The window never opened (no graphics adapter, a device that wouldn't start): once more on
/// the software renderer, else a message box saying what failed instead of a silent exit.
fn no_window(error: eframe::Error, tried_cpu: bool, settings: Option<&std::path::Path>) -> eframe::Result {
log::error!("the window could not open: {error}");
eprintln!("SolidCraft: the window could not open: {error}");
if !tried_cpu && let Ok(exe) = std::env::current_exe() {
eprintln!("SolidCraft: trying again with the software renderer ({}=cpu)", startup::RENDERER_ENV);
match std::process::Command::new(exe).args(std::env::args_os().skip(1)).env(startup::RENDERER_ENV, "cpu").status() {
Ok(status) if status.success() => return Ok(()),
// The second try showed its own message.
Ok(_) => return Err(error),
Err(e) => log::error!("could not start again with the software renderer: {e}"),
}
}
let _ = rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("SolidCraft could not start")
.set_description(startup::no_window_message(&error.to_string(), settings))
.set_buttons(rfd::MessageButtons::Ok)
.show();
Err(error)
}
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
#[cfg(all(unix, not(target_os = "macos")))]
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
match wayland_override.map(str::trim) {
Some("0") => return display,
Some(_) => return false,
None => {}
}
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
display && !(niri && wayland_display)
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod backend_tests {
use super::prefer_x11;
#[test]
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
// XWayland whenever there's an X display, for file drag-and-drop.
assert!(prefer_x11(true, true, Some("GNOME"), None));
assert!(prefer_x11(true, false, None, None));
assert!(!prefer_x11(false, true, Some("KDE"), None));
// niri (alone or in a desktop list) under Wayland stays native.
assert!(!prefer_x11(true, true, Some("niri"), None));
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
// niri without a Wayland socket (nested X session) still uses X11.
assert!(prefer_x11(true, false, Some("niri"), None));
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
}
}
#[cfg(test)]
mod icon_tests {
#[cfg(target_os = "macos")]
#[test]
fn macos_preserves_the_bundle_icon_instead_of_setting_a_runtime_bitmap() {
let viewport = super::app_viewport().expect("viewport");
assert_eq!(viewport.icon.as_deref(), Some(&eframe::egui::IconData::default()));
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn desktop_viewport_supplies_the_original_mark_to_the_window() {
let viewport = super::app_viewport().expect("viewport");
let icon = viewport.icon.expect("running window must have an icon");
assert_eq!((icon.width, icon.height), (64, 64));
assert_eq!(icon.rgba, super::app_icon::rgba(64).expect("original mark"));
assert_eq!(viewport.app_id.as_deref(), Some(super::APP_ID));
}
}
#[cfg(test)]
mod msaa_tests {
#[test]
fn first_launch_adopts_the_previous_release_settings() {
let d = std::env::temp_dir().join(format!("solidcraft-adopt-settings-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let (current, legacy, legacy_eframe) = (d.join("solidcraft"), d.join("solvecraft"), d.join("ai.storyteller.solvecraft"));
std::fs::create_dir_all(&legacy).unwrap();
std::fs::create_dir_all(&legacy_eframe).unwrap();
std::fs::write(legacy.join("graphics.json"), r#"{"msaa":8}"#).unwrap();
std::fs::write(legacy_eframe.join("app.ron"), "(prefs)").unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("graphics.json")).unwrap(), r#"{"msaa":8}"#);
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(prefs)");
assert!(legacy.join("graphics.json").is_file() && legacy_eframe.join("app.ron").is_file());
// Later launches leave the current settings alone.
std::fs::write(current.join("app.ron"), "(mine)").unwrap();
std::fs::remove_file(current.join("graphics.json")).unwrap();
super::adopt_settings(&current, None, &legacy, Some(&legacy_eframe)).unwrap();
assert_eq!(std::fs::read_to_string(current.join("app.ron")).unwrap(), "(mine)");
assert!(!current.join("graphics.json").exists());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn saved_multisampling_uses_the_selected_backend_policy() {
use eframe::wgpu::Backend;
for requested in [1, 2, 4, 8] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(Backend::Gl, requested), 1);
for backend in [Backend::Vulkan, Backend::Dx12, Backend::Metal] {
assert_eq!(eframe::egui_wgpu::effective_msaa_samples(backend, requested), requested);
}
}
}
}
-285
View File
@@ -1,285 +0,0 @@
//! Windows: what a crash in native code leaves behind (docs/crash-reports.md).
//!
//! A crash inside a graphics driver or another DLL (an access violation, a stack overflow) is no
//! Rust panic: the panic hook never runs and the next start only found "an unclean exit". An
//! unhandled-exception filter now writes, before the process goes, a small JSON file with the
//! exception code, the faulting module and the offset into it, and a minidump, next to the run's
//! crash journal (`solidcraft_engine::crash::native_paths`). The next start puts both into the
//! unclean exit's report, with the startup step the journal says the app was in.
//!
//! Installing an exception filter and asking the system which module holds an address have no
//! safe API, hence the `unsafe_code` allowance on this module (the app crate denies it; see
//! `open_documents.rs` for the other one). The filter runs on a thread that may have a broken
//! heap or almost no stack left: it doesn't allocate, formats into a buffer on the stack, and
//! skips the minidump after a stack overflow.
#![allow(unsafe_code)]
use std::fmt::Write as _;
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, HANDLE, HMODULE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{CREATE_ALWAYS, CreateFileW, FILE_ATTRIBUTE_NORMAL, WriteFile};
use windows_sys::Win32::System::Diagnostics::Debug::{
EXCEPTION_CONTINUE_SEARCH, EXCEPTION_POINTERS, MINIDUMP_EXCEPTION_INFORMATION, MiniDumpNormal, MiniDumpWithThreadInfo,
MiniDumpWithUnloadedModules, MiniDumpWriteDump, SetUnhandledExceptionFilter,
};
use windows_sys::Win32::System::LibraryLoader::{
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, GetModuleFileNameW, GetModuleHandleExW,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetCurrentProcessId, GetCurrentThreadId};
/// Where the filter writes (NUL-terminated UTF-16), set once by [`install`].
struct Paths {
info: Vec<u16>,
dump: Vec<u16>,
}
static PATHS: OnceLock<Paths> = OnceLock::new();
/// The filter that was installed before ours (called after ours), as an address.
static PREVIOUS: AtomicUsize = AtomicUsize::new(0);
/// The filter has started writing (a second exception while it does is left alone).
static BUSY: AtomicBool = AtomicBool::new(false);
const STATUS_ACCESS_VIOLATION: u32 = 0xC000_0005;
const STATUS_IN_PAGE_ERROR: u32 = 0xC000_0006;
const STATUS_STACK_OVERFLOW: u32 = 0xC000_00FD;
/// Catch native crashes from now on (after `solidcraft_engine::crash::install`; nothing without it).
pub fn install() {
let Some((info, dump)) = solidcraft_engine::crash::native_paths() else { return };
let wide = |p: &Path| p.as_os_str().encode_wide().chain([0]).collect::<Vec<u16>>();
if PATHS.set(Paths { info: wide(&info), dump: wide(&dump) }).is_ok() {
set_filter();
}
}
/// Put the filter back on top: a DLL loaded since (a graphics driver) may have set its own,
/// which is then called after ours.
pub fn reinstall() {
if PATHS.get().is_some() {
set_filter();
}
}
fn set_filter() {
// SAFETY: `filter` has the signature the system calls it with and stays valid for the
// process's life.
let previous = unsafe { SetUnhandledExceptionFilter(Some(filter)) };
if let Some(p) = previous
&& p as usize != filter as *const () as usize
{
PREVIOUS.store(p as usize, Ordering::SeqCst);
}
}
/// The unhandled-exception filter: record, then let the previous filter (or the system) go on.
unsafe extern "system" fn filter(pointers: *const EXCEPTION_POINTERS) -> i32 {
if !BUSY.swap(true, Ordering::SeqCst) {
// SAFETY: the system passes valid exception pointers for the duration of the call.
unsafe { record(pointers) };
}
let previous = PREVIOUS.load(Ordering::SeqCst);
if previous != 0 && previous != filter as *const () as usize {
// SAFETY: the address came from `SetUnhandledExceptionFilter` as a filter of this type.
let previous: unsafe extern "system" fn(*const EXCEPTION_POINTERS) -> i32 = unsafe { std::mem::transmute(previous) };
// SAFETY: as for this filter.
return unsafe { previous(pointers) };
}
EXCEPTION_CONTINUE_SEARCH
}
/// A fixed buffer to format into without allocating (what doesn't fit is cut off).
struct Buf {
bytes: [u8; 2048],
len: usize,
}
impl std::fmt::Write for Buf {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let room = self.bytes.len().saturating_sub(self.len);
let n = s.len().min(room);
if let (Some(to), Some(from)) = (self.bytes.get_mut(self.len..self.len + n), s.as_bytes().get(..n)) {
to.copy_from_slice(from);
self.len += n;
}
Ok(())
}
}
/// The usual names of the exception codes a crash shows.
fn code_name(code: u32) -> &'static str {
match code {
STATUS_ACCESS_VIOLATION => "EXCEPTION_ACCESS_VIOLATION",
STATUS_IN_PAGE_ERROR => "EXCEPTION_IN_PAGE_ERROR",
STATUS_STACK_OVERFLOW => "EXCEPTION_STACK_OVERFLOW",
0xC000_001D => "EXCEPTION_ILLEGAL_INSTRUCTION",
0xC000_0094 => "EXCEPTION_INT_DIVIDE_BY_ZERO",
0xC000_0096 => "EXCEPTION_PRIV_INSTRUCTION",
0xC000_008C => "EXCEPTION_ARRAY_BOUNDS_EXCEEDED",
0xC000_0374 => "STATUS_HEAP_CORRUPTION",
0xC000_0409 => "STATUS_STACK_BUFFER_OVERRUN",
0xC000_0602 => "STATUS_FAIL_FAST_EXCEPTION",
0x8000_0003 => "EXCEPTION_BREAKPOINT",
0xE06D_7363 => "C++ exception",
_ => "exception",
}
}
/// Write what `pointers` says into the info file, then the minidump.
unsafe fn record(pointers: *const EXCEPTION_POINTERS) {
let Some(paths) = PATHS.get() else { return };
if pointers.is_null() {
return;
}
// SAFETY: checked non-null; the system keeps it valid during the filter.
let rec = unsafe { (*pointers).ExceptionRecord };
if rec.is_null() {
return;
}
// SAFETY: as above.
let rec = unsafe { &*rec };
let code = rec.ExceptionCode as u32;
let address = rec.ExceptionAddress as usize;
let mut module: HMODULE = std::ptr::null_mut();
let mut name = [0u16; 512];
// SAFETY: FROM_ADDRESS takes the address in place of a name; UNCHANGED_REFCOUNT means no
// handle to release; `name` is as long as we say.
let len = unsafe {
if GetModuleHandleExW(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
address as *const u16,
&mut module,
) != 0
{
GetModuleFileNameW(module, name.as_mut_ptr(), name.len() as u32) as usize
} else {
0
}
};
// SAFETY: no preconditions.
let thread = unsafe { GetCurrentThreadId() };
let mut b = Buf { bytes: [0; 2048], len: 0 };
let _ = write!(b, "{{\"code\":\"0x{code:08X}\",\"name\":\"{}\",\"address\":\"0x{address:016X}\",\"thread\":{thread}", code_name(code));
if len > 0 {
let _ = write!(b, ",\"offset\":\"0x{:X}\",\"module\":\"", address.wrapping_sub(module as usize));
for c in char::decode_utf16(name.iter().take(len).copied()) {
let _ = match c.unwrap_or(char::REPLACEMENT_CHARACTER) {
'"' => b.write_str("\\\""),
'\\' => b.write_str("\\\\"),
c if (c as u32) < 0x20 => b.write_str("?"),
c => b.write_char(c),
};
}
let _ = b.write_str("\"");
}
if matches!(code, STATUS_ACCESS_VIOLATION | STATUS_IN_PAGE_ERROR) && rec.NumberParameters >= 2 {
let what = match rec.ExceptionInformation[0] {
0 => "read of",
1 => "write to",
8 => "execution at",
_ => "access to",
};
let _ = write!(b, ",\"detail\":\"{what} 0x{:016X}\"", rec.ExceptionInformation[1]);
}
let _ = b.write_str("}");
let bytes = b.bytes.get(..b.len).unwrap_or_default();
// SAFETY: NUL-terminated path; the handle is closed below.
unsafe {
let file = create(&paths.info);
if !file.is_null() {
let mut written = 0u32;
WriteFile(file, bytes.as_ptr(), bytes.len() as u32, &mut written, std::ptr::null_mut());
CloseHandle(file);
}
}
// After a stack overflow there is no stack left for writing a minidump.
if code == STATUS_STACK_OVERFLOW {
return;
}
// SAFETY: as above; the exception information points at this thread's exception.
unsafe {
let file = create(&paths.dump);
if !file.is_null() {
let exception = MINIDUMP_EXCEPTION_INFORMATION { ThreadId: thread, ExceptionPointers: pointers as *mut _, ClientPointers: 0 };
MiniDumpWriteDump(
GetCurrentProcess(),
GetCurrentProcessId(),
file,
MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules,
&exception,
std::ptr::null(),
std::ptr::null(),
);
CloseHandle(file);
}
}
}
/// A new file at `path` (NUL-terminated) for writing, or null.
unsafe fn create(path: &[u16]) -> HANDLE {
// SAFETY: the caller passes a NUL-terminated path.
let h = unsafe { CreateFileW(path.as_ptr(), GENERIC_WRITE, 0, std::ptr::null(), CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, std::ptr::null_mut()) };
if h == INVALID_HANDLE_VALUE { std::ptr::null_mut() } else { h }
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
/// The child half: install crash reporting and the filter, then fault in native code.
#[test]
fn child() {
let Some(dir) = std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_CHILD") else { return };
let build = solidcraft_engine::crash::Build { version: "9.9.9", commit: "abc1234", built: "2026-10-14" };
let fresh = solidcraft_engine::crash::install(PathBuf::from(dir), build);
println!("FRESH {}", serde_json::to_string(&fresh).unwrap_or_default());
if std::env::var_os("SOLIDCRAFT_NATIVE_CRASH_FAULT").is_none() {
solidcraft_engine::crash::end_session();
return;
}
super::install();
solidcraft_engine::crash::phase("device creation");
// SAFETY: none; this is the crash under test (a read of address 16, as a null struct
// pointer's field would be).
let v = unsafe { std::ptr::read_volatile(16 as *const u64) };
println!("unreachable {v}");
}
fn run(dir: &std::path::Path, fault: bool) -> std::process::Output {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "native_crash::tests::child", "--test-threads=1", "--nocapture"]).env("SOLIDCRAFT_NATIVE_CRASH_CHILD", dir);
if fault {
c.env("SOLIDCRAFT_NATIVE_CRASH_FAULT", "1");
}
c.output().unwrap()
}
#[test]
fn an_access_violation_is_reported_with_its_module_step_and_minidump() {
let dir = std::env::temp_dir().join(format!("solidcraft-native-crash-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let out = run(&dir, true);
assert_eq!(out.status.code().map(|c| c as u32), Some(super::STATUS_ACCESS_VIOLATION), "{out:?}");
let sessions: Vec<PathBuf> = std::fs::read_dir(dir.join("sessions")).unwrap().flatten().map(|e| e.path()).collect();
let info = sessions.iter().find(|p| p.to_string_lossy().ends_with(".native.json")).expect("the filter wrote what it caught");
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(info).unwrap()).unwrap();
assert_eq!(v["code"], "0xC0000005", "{v}");
assert_eq!(v["detail"], "read of 0x0000000000000010", "{v}");
assert!(v["module"].as_str().unwrap().to_lowercase().contains("solidcraft"), "the fault is in the test binary: {v}");
let dump = sessions.iter().find(|p| p.extension().is_some_and(|e| e == "dmp")).expect("a minidump");
assert_eq!(std::fs::read(dump).unwrap().get(..4), Some(&b"MDMP"[..]));
// The next start reports it.
let out = run(&dir, false);
assert!(out.status.success(), "{out:?}");
let reports = solidcraft_engine::crash::list(&dir);
assert_eq!(reports.len(), 1, "{reports:?}");
let r = solidcraft_engine::crash::read(&dir, &reports[0].id).unwrap();
assert!(r["message"].as_str().unwrap().contains("EXCEPTION_ACCESS_VIOLATION (0xC0000005) in solidcraft"), "{r}");
assert!(r["message"].as_str().unwrap().contains("during startup (device creation"), "{r}");
assert!(r.get("minidump_path").is_some(), "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}
-120
View File
@@ -1,120 +0,0 @@
//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with
//! `open -a SolidCraft file` (#997).
//!
//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's
//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`,
//! which fails with "SolidCraft cannot open files in the … format" because SolidCraft isn't an
//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to
//! the delegate's class when the app is about to finish launching: after winit set the delegate and
//! before AppKit delivers the files the app was launched with. The handler queues the paths and
//! wakes the UI, which opens them with [`take`] like files named on the command line.
//!
//! Adding a method to another crate's Objective-C class has no safe API, hence the `unsafe_code`
//! allowance on [`install`] and [`add_open_urls_method`] (winit overrides `sendEvent:` on
//! `NSApplication` similarly).
use std::ptr::NonNull;
use std::sync::{Mutex, OnceLock, PoisonError};
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2::runtime::{AnyObject, Imp, Sel};
use objc2::sel;
use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification};
use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL};
/// Paths that arrived and haven't been opened yet.
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = OnceLock::new();
/// Handle open-document events from now on. Call before the event loop runs.
#[allow(unsafe_code)]
pub fn install() {
let block = RcBlock::new(|_: NonNull<NSNotification>| add_open_urls_method());
// SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block
// runs on the posting thread (AppKit posts it on the main thread). The block captures nothing,
// so it is sendable. The notification center keeps the registration (and the block) for the
// life of the app.
unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(NSApplicationWillFinishLaunchingNotification),
None,
None,
&block,
);
}
}
/// Wake `ctx` when files arrive while the app runs.
pub fn set_ui(ctx: &egui::Context) {
// Set once; a second call would hand over the same context.
let _ = UI.set(ctx.clone());
}
/// The paths that arrived since the last call.
pub fn take() -> Vec<String> {
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
}
/// `application:openURLs:` on the application delegate's class.
#[allow(unsafe_code)]
fn add_open_urls_method() {
let Some(mtm) = MainThreadMarker::new() else { return };
let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else {
log::warn!("no application delegate; files opened from Finder won't open");
return;
};
let class = AsRef::<AnyObject>::as_ref(&*delegate).class();
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = open_urls;
// SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls
// it with the arguments the type encoding declares, which match `open_urls`: no return value
// (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`).
let added = unsafe {
let imp = std::mem::transmute::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), Imp>(handler);
objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr())
};
if !added.as_bool() {
log::warn!("the application delegate already handles open-document events");
}
}
/// AppKit calls this; a panic stops here instead of unwinding through AppKit's frames.
extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray<NSURL>) {
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| queue(urls))).is_err() {
log::warn!("files opened from Finder were dropped after an internal error");
}
}
/// Queue the files among `urls` and wake the UI.
fn queue(urls: &NSArray<NSURL>) {
// Only file URLs: macOS 27 turns an https URL's path into a file path too.
let paths = urls.iter().filter(|u| u.isFileURL()).filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned());
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths);
if let Some(ctx) = UI.get() {
ctx.request_repaint();
}
}
#[cfg(test)]
mod tests {
use objc2::runtime::NSObject;
use objc2_foundation::NSString;
use super::*;
/// Files queue until the UI takes them; URLs that aren't files are left out.
#[test]
fn opened_files_wait_for_the_ui() {
let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.solidcraft")).unwrap();
let urls = NSArray::from_retained_slice(&[
NSURL::from_file_path("/tmp/a b.solidcraft").unwrap(),
web,
NSURL::from_file_path("/tmp/c.step").unwrap(),
]);
let any = NSObject::new();
open_urls(&any, sel!(application:openURLs:), &any, &urls);
assert_eq!(take(), ["/tmp/a b.solidcraft", "/tmp/c.step"]);
assert!(take().is_empty());
}
}
-669
View File
@@ -1,669 +0,0 @@
//! 3Dconnexion HID navigation on Windows (through hidapi) and Linux (through hidraw). The device
//! reader never touches the document or UI; it publishes the latest axes, and the egui thread
//! applies them once per frame.
mod hid_descriptor;
#[cfg(target_os = "linux")]
mod hidraw;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use egui::Context;
use hid_descriptor::Layout;
#[cfg(target_os = "windows")]
use hidapi::{DeviceInfo, HidApi};
use quick_xml::Reader;
use quick_xml::events::Event;
use solidcraft_engine::render::Camera;
use solidcraft_ui_egui::SolveApp;
const STALE_AFTER: Duration = Duration::from_millis(150);
const RETRY_AFTER: Duration = Duration::from_secs(2);
const MAX_PROFILE_BYTES: u64 = 1024 * 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Axes {
translation: [i16; 3],
rotation: [i16; 3],
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AxisRule {
output: usize,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct DriverMapping {
axes: [AxisRule; 6],
overall_scale: f64,
}
impl Default for DriverMapping {
fn default() -> Self {
Self { axes: std::array::from_fn(|output| AxisRule { output, enabled: true, reversed: false, scale: 1.0 }), overall_scale: 1.0 }
}
}
impl DriverMapping {
/// The mapping used when no 3DxWare profile is found. On Linux there is no 3DxWare, so it is
/// what the driver applies by default in object mode (#1172): the physical Ry and Rz swapped
/// and reversed, so a tilt left/right rolls and a twist spins. Elsewhere the driver writes a
/// profile, and the raw axes are used until it does.
fn without_profile() -> Self {
let mut mapping = Self::default();
if cfg!(target_os = "linux") {
mapping.axes[4] = AxisRule { output: 5, enabled: true, reversed: true, scale: 1.0 };
mapping.axes[5] = AxisRule { output: 4, enabled: true, reversed: true, scale: 1.0 };
}
mapping
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Motion {
translation: [f64; 3],
rotation: [f64; 3],
}
impl DriverMapping {
fn apply(self, axes: Axes) -> Motion {
let raw = [
normalized(axes.translation[0]),
normalized(axes.translation[1]),
normalized(axes.translation[2]),
normalized(axes.rotation[0]),
normalized(axes.rotation[1]),
normalized(axes.rotation[2]),
];
let mut logical = [0.0; 6];
for (input, rule) in self.axes.iter().enumerate() {
if rule.enabled {
let sign = if rule.reversed { -1.0 } else { 1.0 };
logical[rule.output] = raw[input] * sign * rule.scale * self.overall_scale;
}
}
Motion { translation: [logical[0], logical[1], logical[2]], rotation: [logical[3], logical[4], logical[5]] }
}
}
#[derive(Clone, Copy, Debug, Default)]
struct ProfileAxis {
input: Option<usize>,
output: Option<usize>,
enabled: bool,
reversed: bool,
scale: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum ProfilePart {
Input,
Output,
}
fn axis_index(name: &str) -> Option<usize> {
match name.trim() {
"HIDMultiAxis_X" => Some(0),
"HIDMultiAxis_Y" => Some(1),
"HIDMultiAxis_Z" => Some(2),
"HIDMultiAxis_Rx" => Some(3),
"HIDMultiAxis_Ry" => Some(4),
"HIDMultiAxis_Rz" => Some(5),
_ => None,
}
}
/// 3DxWare stores the active KMJ application's axis selection, swapping, inversion and speed in
/// `%APPDATA%/3Dconnexion/3DxWare/Cfg/<app>-KMJ.xml`. Direct HID reads bypass the driver, so apply
/// that profile explicitly when it exists. Unknown or incomplete fields retain the raw HID axis.
fn parse_driver_mapping(xml: &[u8]) -> Option<DriverMapping> {
let mut reader = Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut buf = Vec::new();
let mut mapping = DriverMapping::default();
let mut axis: Option<ProfileAxis> = None;
let mut part = None;
let mut field = Vec::new();
let mut found = false;
loop {
match reader.read_event_into(&mut buf).ok()? {
Event::Start(start) => match start.local_name().as_ref() {
b"Axis" => axis = Some(ProfileAxis { enabled: true, scale: 1.0, ..Default::default() }),
b"Input" => part = Some(ProfilePart::Input),
b"Output" => part = Some(ProfilePart::Output),
name @ (b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale") => field = name.to_vec(),
_ => {}
},
Event::Text(text) => {
let value = String::from_utf8_lossy(text.as_ref());
match field.as_slice() {
b"ActionID" => {
if let Some(current) = axis.as_mut() {
match part {
Some(ProfilePart::Input) => current.input = axis_index(&value),
Some(ProfilePart::Output) => current.output = axis_index(&value),
None => {}
}
}
}
b"Enabled" => {
if let Some(current) = axis.as_mut() {
current.enabled = value.trim().eq_ignore_ascii_case("true");
}
}
b"Reversed" => {
if part == Some(ProfilePart::Output)
&& let Some(current) = axis.as_mut()
{
current.reversed = value.trim().eq_ignore_ascii_case("true");
}
}
b"Scale" => {
if part == Some(ProfilePart::Output)
&& let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite())
&& let Some(current) = axis.as_mut()
{
current.scale = scale.clamp(0.0, 10.0);
}
}
b"OverallScale" => {
if let Some(scale) = value.trim().parse::<f64>().ok().filter(|scale| scale.is_finite()) {
mapping.overall_scale = scale.clamp(0.0, 10.0);
}
}
_ => {}
}
}
Event::End(end) => match end.local_name().as_ref() {
b"Axis" => {
if let Some(current) = axis.take()
&& let (Some(input), Some(output)) = (current.input, current.output)
{
mapping.axes[input] = AxisRule { output, enabled: current.enabled, reversed: current.reversed, scale: current.scale };
found = true;
}
}
b"Input" | b"Output" => part = None,
b"ActionID" | b"Enabled" | b"Reversed" | b"Scale" | b"OverallScale" => field.clear(),
_ => {}
},
Event::Eof => break,
_ => {}
}
buf.clear();
}
found.then_some(mapping)
}
fn load_driver_mapping() -> Option<DriverMapping> {
let cfg = std::path::PathBuf::from(std::env::var_os("APPDATA")?).join("3Dconnexion").join("3DxWare").join("Cfg");
// The driver names the profile after the executable; one made for the pre-rename executable
// still applies until the driver writes a new one (compatibility shim).
let profile = ["solidcraft-KMJ.xml", "solvecraft-KMJ.xml"].into_iter().map(|n| cfg.join(n)).find(|p| p.is_file())?;
if std::fs::metadata(&profile).ok()?.len() > MAX_PROFILE_BYTES {
log::warn!("3D mouse: ignoring oversized 3DxWare profile {}", profile.display());
return None;
}
let xml = std::fs::read(&profile).ok()?;
parse_driver_mapping(&xml)
}
#[derive(Clone, Copy)]
struct Reading {
axes: Axes,
translation_at: Instant,
rotation_at: Instant,
}
impl Reading {
fn new(now: Instant) -> Self {
Self { axes: Axes::default(), translation_at: now, rotation_at: now }
}
fn current(self, now: Instant) -> Axes {
Axes {
translation: if now.duration_since(self.translation_at) < STALE_AFTER { self.axes.translation } else { [0; 3] },
rotation: if now.duration_since(self.rotation_at) < STALE_AFTER { self.axes.rotation } else { [0; 3] },
}
}
}
/// The old SpaceNavigator uses Logitech's VID; newer 3Dconnexion devices use their own.
fn is_3d_mouse_vendor(vendor_id: u16) -> bool {
matches!(vendor_id, 0x046d | 0x256f)
}
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
#[cfg(target_os = "windows")]
fn is_3d_mouse(info: &DeviceInfo) -> bool {
is_3d_mouse_vendor(info.vendor_id()) && info.usage_page() == 0x01 && info.usage() == 0x08
}
fn three_axes(bytes: &[u8]) -> Option<[i16; 3]> {
let &[x0, x1, y0, y1, z0, z1] = bytes.get(..6)? else { return None };
Some([i16::from_le_bytes([x0, x1]), i16::from_le_bytes([y0, y1]), i16::from_le_bytes([z0, z1])])
}
/// SpaceNavigator reports 1 (translation) and 2 (rotation), each with three signed
/// little-endian 16-bit values. The HID layer may pad either report to its maximum size.
/// Some homemade devices send all six values in report 1 instead (`Layout::Combined`): only the
/// device's report descriptor can tell the two apart, because a padded split report 1 is as long.
fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Instant) -> bool {
match report.first().copied() {
Some(1) if layout == Layout::Combined => {
let Some(body) = report.get(1..) else { return false };
let (Some(translation), Some(rotation)) = (body.get(..6).and_then(three_axes), body.get(6..).and_then(three_axes)) else { return false };
reading.axes = Axes { translation, rotation };
reading.translation_at = now;
reading.rotation_at = now;
true
}
Some(1) => {
let Some(translation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.translation = translation;
reading.translation_at = now;
true
}
Some(2) => {
let Some(rotation) = report.get(1..).and_then(three_axes) else { return false };
reading.axes.rotation = rotation;
reading.rotation_at = now;
true
}
_ => false,
}
}
/// Publishes one input report from the device to the egui thread.
fn publish(ctx: &Context, shared: &Mutex<Reading>, layout: Layout, report: &[u8]) {
if let Ok(mut reading) = shared.lock()
&& decode_report(report, layout, &mut reading, Instant::now())
{
ctx.request_repaint();
}
}
/// The device is gone: stop reporting its last position.
fn reset(ctx: &Context, shared: &Mutex<Reading>) {
if let Ok(mut reading) = shared.lock() {
*reading = Reading::new(Instant::now());
}
ctx.request_repaint();
}
#[cfg(target_os = "windows")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
let Ok(mut api) = HidApi::new() else {
log::warn!("3D mouse: HID initialization failed");
return;
};
loop {
if let Err(error) = api.refresh_devices() {
log::warn!("3D mouse: device scan failed: {error}");
} else if let Some(device) = api.device_list().find(|info| is_3d_mouse(info)).and_then(|info| info.open_device(&api).ok()) {
let layout = device_layout(&device);
let mut report = [0_u8; 64];
loop {
match device.read_timeout(&mut report, 100) {
Ok(0) => {}
Ok(size) => publish(&ctx, &shared, layout, report.get(..size).unwrap_or_default()),
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
reset(&ctx, &shared);
}
std::thread::sleep(RETRY_AFTER);
}
}
/// How the device packs its axes, from its report descriptor; the usual split layout if the
/// descriptor cannot be read.
#[cfg(target_os = "windows")]
fn device_layout(device: &hidapi::HidDevice) -> Layout {
let mut descriptor = [0_u8; hidapi::MAX_REPORT_DESCRIPTOR_SIZE];
match device.get_report_descriptor(&mut descriptor) {
Ok(size) => hid_descriptor::Descriptor::parse(descriptor.get(..size).unwrap_or_default()).layout(),
Err(error) => {
log::info!("3D mouse: no report descriptor, assuming separate translation and rotation reports: {error}");
Layout::Split
}
}
}
/// Reads `/dev/hidrawN` with plain blocking reads, which end with an error when the device is
/// unplugged. Opening needs permission: most distributions grant it to the logged-in user for
/// devices that carry the `uaccess` udev tag, which a rule for the 3D mouse has to add.
#[cfg(target_os = "linux")]
fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
use std::io::Read;
let mut denied = None;
loop {
if let Some(device) = hidraw::find() {
match std::fs::File::open(&device.path) {
Ok(mut file) => {
denied = None;
log::info!("3D mouse: reading {} ({:?} reports)", device.path.display(), device.layout);
let mut report = [0_u8; 64];
loop {
match file.read(&mut report) {
Ok(0) => break,
Ok(size) => publish(&ctx, &shared, device.layout, report.get(..size).unwrap_or_default()),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => {
log::info!("3D mouse disconnected: {error}");
break;
}
}
}
reset(&ctx, &shared);
}
// Say so once per device node, not every retry.
Err(error) if denied.as_ref() != Some(&device.path) => {
log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display());
denied = Some(device.path);
}
Err(_) => {}
}
}
std::thread::sleep(RETRY_AFTER);
}
}
pub struct SpaceMouse {
shared: Arc<Mutex<Reading>>,
last_frame: Instant,
driver_mapping: DriverMapping,
mapping_checked_at: Instant,
}
impl SpaceMouse {
pub fn start(ctx: &Context) -> Self {
let now = Instant::now();
let shared = Arc::new(Mutex::new(Reading::new(now)));
let worker_state = Arc::clone(&shared);
let worker_ctx = ctx.clone();
if let Err(error) = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
log::warn!("3D mouse: reader could not start: {error}");
}
Self {
shared,
last_frame: now,
driver_mapping: load_driver_mapping().unwrap_or_else(DriverMapping::without_profile),
mapping_checked_at: now,
}
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) {
let now = Instant::now();
let seconds = now.duration_since(self.last_frame).as_secs_f64().min(0.05);
self.last_frame = now;
if now.duration_since(self.mapping_checked_at) >= RETRY_AFTER {
let updated = load_driver_mapping().unwrap_or_else(DriverMapping::without_profile);
if updated != self.driver_mapping {
log::info!("3D mouse: applying updated 3DxWare axis profile");
self.driver_mapping = updated;
}
self.mapping_checked_at = now;
}
if !app.preferences.space_mouse_enabled || app.home.open {
return;
}
let axes = match self.shared.lock() {
Ok(reading) => reading.current(now),
Err(_) => return,
};
if axes == Axes::default() {
return;
}
let speed = f64::from(app.preferences.space_mouse_speed);
if !speed.is_finite() {
return;
}
app.begin_external_navigation();
let height = app.viewport.rect.map_or(600.0, |rect| f64::from(rect.height().max(1.0)));
navigate(
&mut app.cam,
self.driver_mapping.apply(axes),
seconds * speed.clamp(0.1, 5.0),
height,
app.session.active_sketch.is_some(),
app.preferences.zoom_reverse,
);
ctx.request_repaint_after(Duration::from_millis(16));
}
}
/// A small dead zone avoids drift at rest. A full cap deflection takes roughly one viewport
/// per second to pan and about two radians per second to turn, independent of frame rate.
fn normalized(value: i16) -> f64 {
let v = f64::from(value);
if v.abs() <= 20.0 { 0.0 } else { v.signum() * ((v.abs() - 20.0) / 330.0).min(1.0) }
}
fn navigate(cam: &mut Camera, motion: Motion, seconds: f64, height: f64, sketch: bool, reverse_zoom: bool) {
if !(seconds > 0.0) || !seconds.is_finite() {
return;
}
let [x, y, z] = motion.translation;
let [rx, ry, rz] = motion.rotation;
// Logical 3DxWare coordinates are X = left/right, Y = zoom and Z = up/down. The user's
// profile above can swap the physical forward/back and up/down axes before they reach here.
cam.pan(x * height * 0.6 * seconds, z * height * 0.6 * seconds, height);
let zoom_sign = if reverse_zoom { -1.0 } else { 1.0 };
cam.zoom_at((-y * zoom_sign * 1.5 * seconds).exp(), None);
if !sketch {
// These are logical 3DxWare coordinates after the profile mapping above: Rx is Tilt,
// Ry is Roll and Rz is Spin. The KMJ profile swaps physical Ry/Rz for the selected
// navigation mode, so swapping them again here would make Roll behave like Spin.
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
cam.roll_by(rz * 2.0 * seconds);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn raw(axes: Axes) -> Motion {
DriverMapping::default().apply(axes)
}
#[test]
fn decodes_separate_reports_without_stale_motion_or_padding_interference() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 2], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], Layout::Split, &mut reading, now));
assert_eq!(reading.current(now).rotation, [0, 1, -1]);
let later = now + STALE_AFTER + Duration::from_millis(1);
assert_eq!(reading.current(later), Axes::default());
assert!(decode_report(&[2, 4, 0, 5, 0, 6, 0], Layout::Split, &mut reading, later));
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], Layout::Split, &mut reading, later));
assert_eq!(reading.current(later), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn combined_report_one_carries_translation_and_rotation_together() {
let now = Instant::now();
let mut reading = Reading::new(now);
// Report id 1, then X Y Z Rx Ry Rz as signed little-endian 16-bit values.
let report = [1, 0x9c, 0xff, 0x2c, 0x01, 0, 0, 1, 0, 0, 0, 0xff, 0xff];
assert!(decode_report(&report, Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [-100, 300, 0], rotation: [1, 0, -1] });
// The same bytes on a split device are only the translation report; its padding is not rotation.
let mut split = Reading::new(now);
assert!(decode_report(&report, Layout::Split, &mut split, now));
assert_eq!(split.current(now), Axes { translation: [-100, 300, 0], rotation: [0; 3] });
}
#[test]
fn combined_layout_ignores_short_and_other_reports() {
let now = Instant::now();
let mut reading = Reading::new(now);
assert!(!decode_report(&[1, 1, 0, 2, 0, 3, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[3, 1, 0, 0, 0, 0], Layout::Combined, &mut reading, now));
assert!(!decode_report(&[], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes::default());
// Extra padding after the six values is fine.
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 0, 0], Layout::Combined, &mut reading, now));
assert_eq!(reading.current(now), Axes { translation: [1, 2, 3], rotation: [4, 5, 6] });
}
#[test]
fn six_axes_move_the_camera_and_sketches_keep_their_orientation() {
let mut cam = Camera::default();
let before = cam;
navigate(&mut cam, raw(Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }), 0.02, 600.0, false, false);
assert_ne!(cam.target, before.target);
assert!(cam.distance < before.distance);
assert_ne!(cam.yaw, before.yaw);
assert_ne!(cam.pitch, before.pitch);
assert_ne!(cam.roll, before.roll);
let orientation = (cam.yaw, cam.pitch, cam.roll);
navigate(&mut cam, raw(Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }), 0.02, 600.0, true, false);
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
assert!(cam.is_valid());
}
#[test]
fn individual_axes_have_physical_spacemouse_meaning() {
let before = Camera::default();
let mut slide = before;
navigate(&mut slide, raw(Axes { translation: [350, 0, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(slide.target, before.target);
assert_eq!(slide.distance, before.distance);
let mut push = before;
navigate(&mut push, raw(Axes { translation: [0, 350, 0], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_eq!(push.target, before.target);
assert!(push.distance < before.distance);
let mut lift = before;
navigate(&mut lift, raw(Axes { translation: [0, 0, 350], rotation: [0; 3] }), 0.02, 600.0, false, false);
assert_ne!(lift.target, before.target);
assert_eq!(lift.distance, before.distance);
let mut tilt = before;
navigate(&mut tilt, raw(Axes { translation: [0; 3], rotation: [350, 0, 0] }), 0.02, 600.0, false, false);
assert_eq!((tilt.yaw, tilt.roll), (before.yaw, before.roll));
assert_ne!(tilt.pitch, before.pitch);
let mut roll = before;
navigate(&mut roll, raw(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((roll.pitch, roll.roll), (before.pitch, before.roll));
assert_ne!(roll.yaw, before.yaw);
let mut twist = before;
navigate(&mut twist, raw(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_eq!((twist.yaw, twist.pitch), (before.yaw, before.pitch));
assert_ne!(twist.roll, before.roll);
}
#[test]
fn reverse_zoom_setting_applies_to_spacemouse() {
let before = Camera::default();
let motion = raw(Axes { translation: [0, 350, 0], rotation: [0; 3] });
let mut normal = before;
navigate(&mut normal, motion, 0.02, 600.0, false, false);
let mut reversed = before;
navigate(&mut reversed, motion, 0.02, 600.0, false, true);
assert!(normal.distance < before.distance);
assert!(reversed.distance > before.distance);
}
#[test]
fn applies_3dxware_axis_swaps_and_reversals() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let motion = mapping.apply(Axes { translation: [350, 0, 350], rotation: [350, 0, 350] });
assert_eq!(motion.translation, [1.0, -1.0, 0.0]);
assert_eq!(motion.rotation, [1.0, -1.0, 0.0]);
}
#[test]
fn without_a_profile_linux_uses_the_drivers_object_mode_axes() {
// 3DxWare's object-mode mapping, as a profile (#1172: confirmed on a SpaceMouse Compact).
let object_mode = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_X</ActionID></Input><Output><ActionID>HIDMultiAxis_X</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Y</ActionID></Input><Output><ActionID>HIDMultiAxis_Y</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Z</ActionID></Input><Output><ActionID>HIDMultiAxis_Z</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rx</ActionID></Input><Output><ActionID>HIDMultiAxis_Rx</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>true</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let profile = parse_driver_mapping(object_mode).expect("valid 3DxWare profile");
if cfg!(target_os = "linux") {
assert_eq!(DriverMapping::without_profile(), profile);
// Tilting the cap rolls the view and twisting it spins it, as with the driver.
let before = Camera::default();
let mut tilt = before;
navigate(
&mut tilt,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }),
0.02,
600.0,
false,
false,
);
assert_eq!((tilt.yaw, tilt.pitch), (before.yaw, before.pitch));
assert_ne!(tilt.roll, before.roll);
let mut twist = before;
navigate(
&mut twist,
DriverMapping::without_profile().apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }),
0.02,
600.0,
false,
false,
);
assert_ne!(twist.yaw, before.yaw);
assert_eq!(twist.roll, before.roll);
} else {
assert_eq!(DriverMapping::without_profile(), DriverMapping::default());
}
}
#[test]
fn mapped_roll_and_spin_reach_distinct_camera_rotations() {
let profile = br#"
<AppCfg><Devices><Device><AxisBank>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Ry</ActionID></Input><Output><ActionID>HIDMultiAxis_Rz</ActionID><Reversed>false</Reversed></Output></Axis>
<Axis><Enabled>true</Enabled><Input><ActionID>HIDMultiAxis_Rz</ActionID></Input><Output><ActionID>HIDMultiAxis_Ry</ActionID><Reversed>true</Reversed></Output></Axis>
</AxisBank></Device></Devices></AppCfg>
"#;
let mapping = parse_driver_mapping(profile).expect("valid 3DxWare profile");
let before = Camera::default();
let mut physical_roll = before;
navigate(&mut physical_roll, mapping.apply(Axes { translation: [0; 3], rotation: [0, 350, 0] }), 0.02, 600.0, false, false);
assert_eq!((physical_roll.yaw, physical_roll.pitch), (before.yaw, before.pitch));
assert_ne!(physical_roll.roll, before.roll);
let mut physical_spin = before;
navigate(&mut physical_spin, mapping.apply(Axes { translation: [0; 3], rotation: [0, 0, 350] }), 0.02, 600.0, false, false);
assert_ne!(physical_spin.yaw, before.yaw);
assert_eq!((physical_spin.pitch, physical_spin.roll), (before.pitch, before.roll));
}
}
@@ -1,253 +0,0 @@
//! Just enough of a HID report descriptor reader to recognise a 3D mouse and to see how it packs
//! its motion axes. Written from the public USB HID specification. Malformed input never panics;
//! it simply yields less information.
/// Generic Desktop usage page, and the usages in it that matter here.
const PAGE_GENERIC_DESKTOP: u32 = 0x01;
const USAGE_MULTI_AXIS_CONTROLLER: u32 = 0x08;
const FIRST_AXIS_USAGE: u32 = 0x30; // X, Y, Z, Rx, Ry, Rz are 0x30..=0x35
const AXIS_COUNT: u32 = 6;
/// Motion reports are report id 1 (translation, or everything) and report id 2 (rotation).
const MOTION_REPORT_ID: u8 = 1;
/// Descriptors are small (hidapi reserves 4 KiB). Anything past this is ignored.
const MAX_DESCRIPTOR_BYTES: usize = 4096;
/// Bound on the usages one main item can declare through a usage range.
const MAX_USAGES_PER_ITEM: u32 = 256;
/// How a device spreads its six motion axes over input reports.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Layout {
/// Report 1 carries translation and report 2 carries rotation, three 16-bit values each.
/// This is how 3Dconnexion devices report, and it is the layout assumed when nothing is known.
#[default]
Split,
/// Report 1 carries all six axes: translation, then rotation. Homemade devices do this.
Combined,
}
#[derive(Clone, Copy)]
struct Item {
/// 0 main, 1 global, 2 local (3 is reserved).
kind: u8,
tag: u8,
value: u32,
}
/// Walks the short items of a descriptor. Long items are skipped; a truncated tail ends the walk.
fn items(descriptor: &[u8]) -> impl Iterator<Item = Item> + '_ {
let descriptor = descriptor.get(..MAX_DESCRIPTOR_BYTES).unwrap_or(descriptor);
let mut pos = 0_usize;
std::iter::from_fn(move || {
loop {
let prefix = *descriptor.get(pos)?;
pos += 1;
if prefix == 0xFE {
// Long item: a data length byte, a tag byte, then the data.
let length = usize::from(*descriptor.get(pos)?);
pos = pos.checked_add(2 + length)?;
continue;
}
let size = match prefix & 0b11 {
0 => 0,
1 => 1,
2 => 2,
_ => 4,
};
let data = descriptor.get(pos..pos + size)?;
pos += size;
let value = data.iter().enumerate().fold(0_u32, |value, (index, byte)| value | (u32::from(*byte) << (8 * index)));
return Some(Item { kind: (prefix >> 2) & 0b11, tag: prefix >> 4, value });
}
})
}
/// What a descriptor says about a device, as far as 3D mouse support needs to know.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Descriptor {
multi_axis_controller: bool,
/// Distinct axes (X, Y, Z, Rx, Ry, Rz as bits 0..=5) that input report 1 carries.
motion_report_axes: u8,
}
impl Descriptor {
pub fn parse(descriptor: &[u8]) -> Self {
let mut summary = Self::default();
let mut page = 0_u32;
let mut report_id = 0_u8;
let mut depth = 0_u32;
// Local items belong to the next main item only.
let mut usages: Vec<u32> = Vec::new();
let mut usage_min = None;
for item in items(descriptor) {
match (item.kind, item.tag) {
// Global: usage page, report id.
(1, 0) => page = item.value,
(1, 8) => report_id = u8::try_from(item.value).unwrap_or(0),
// Local: usage, usage minimum, usage maximum.
(2, 0) => usages.push(Self::full_usage(page, item)),
(2, 1) => usage_min = Some(Self::full_usage(page, item)),
(2, 2) => {
if let Some(min) = usage_min.take() {
let max = Self::full_usage(page, item);
let count = max.saturating_sub(min).min(MAX_USAGES_PER_ITEM - 1);
usages.extend((0..=count).map(|offset| min.saturating_add(offset)));
}
}
// Main: input, collection, end collection.
(0, 8) => {
if report_id == MOTION_REPORT_ID || (report_id == 0 && depth > 0) {
summary.note_axes(&usages);
}
}
(0, 0xA) => {
// An application collection (1) at the top level names the device.
if depth == 0 && item.value == 1 && usages.first() == Some(&((PAGE_GENERIC_DESKTOP << 16) | USAGE_MULTI_AXIS_CONTROLLER)) {
summary.multi_axis_controller = true;
}
depth += 1;
}
(0, 0xC) => depth = depth.saturating_sub(1),
_ => {}
}
if item.kind == 0 {
usages.clear();
usage_min = None;
}
}
summary
}
/// Usage items of up to two bytes name a usage on the current page, four bytes carry the page.
fn full_usage(page: u32, item: Item) -> u32 {
if item.value > 0xFFFF { item.value } else { (page << 16) | item.value }
}
fn note_axes(&mut self, usages: &[u32]) {
for usage in usages {
let axis = usage.wrapping_sub((PAGE_GENERIC_DESKTOP << 16) | FIRST_AXIS_USAGE);
if axis < AXIS_COUNT {
self.motion_report_axes |= 1 << axis;
}
}
}
/// True for a top-level Generic Desktop "Multi-Axis Controller" collection. This tells a
/// SpaceMouse's motion interface apart from its keyboard interface and from ordinary mice.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn is_multi_axis_controller(&self) -> bool {
self.multi_axis_controller
}
/// `Combined` when input report 1 declares all six axes, otherwise `Split`.
pub fn layout(&self) -> Layout {
if self.motion_report_axes.count_ones() == AXIS_COUNT { Layout::Combined } else { Layout::Split }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Report 1 = X Y Z, report 2 = Rx Ry Rz, each as three signed 16-bit values.
const SPLIT: &[u8] = &[
0x05, 0x01, // usage page: generic desktop
0x09, 0x08, // usage: multi-axis controller
0xA1, 0x01, // collection: application
0x85, 0x01, // report id 1
0x16, 0xA2, 0xFE, // logical minimum -350
0x26, 0x5E, 0x01, // logical maximum 350
0x75, 0x10, // report size 16
0x95, 0x03, // report count 3
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, // X Y Z
0x81, 0x06, // input: data, variable, relative
0x85, 0x02, // report id 2
0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // Rx Ry Rz
0x81, 0x06, // input
0xC0, // end collection
];
/// Report 1 = X Y Z Rx Ry Rz.
const COMBINED: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, // multi-axis controller, report id 1
0x16, 0xA2, 0xFE, 0x26, 0x5E, 0x01, 0x75, 0x10, 0x95, 0x06, // 6 x 16 bit, -350..350
0x09, 0x30, 0x09, 0x31, 0x09, 0x32, 0x09, 0x33, 0x09, 0x34, 0x09, 0x35, // X Y Z Rx Ry Rz
0x81, 0x06, 0xC0,
];
/// The same six axes declared as a usage range, with report id 1 after the collection opens.
const COMBINED_RANGE: &[u8] = &[
0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x75, 0x10, 0x95, 0x06, //
0x19, 0x30, 0x29, 0x35, // usage minimum X, usage maximum Rz
0x81, 0x06, 0xC0,
];
/// A boot keyboard-like interface: usage page 7 usages must not count as axes.
const KEYBOARD: &[u8] =
&[0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02, 0xC0];
/// An ordinary mouse: Generic Desktop X, Y but top-level usage is Mouse (2).
const MOUSE: &[u8] = &[
0x05, 0x01, 0x09, 0x02, 0xA1, 0x01, 0x09, 0x01, 0xA1, 0x00, 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7F, 0x75, 0x08, 0x95, 0x02, 0x81,
0x06, 0xC0, 0xC0,
];
#[test]
fn split_descriptor_is_a_multi_axis_controller_with_three_axes_in_report_one() {
let descriptor = Descriptor::parse(SPLIT);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0b000_111);
assert_eq!(descriptor.layout(), Layout::Split);
}
#[test]
fn six_axes_in_report_one_are_a_combined_layout() {
for bytes in [COMBINED, COMBINED_RANGE] {
let descriptor = Descriptor::parse(bytes);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Combined);
}
}
#[test]
fn other_interfaces_are_not_3d_mice() {
for bytes in [KEYBOARD, MOUSE, &[][..]] {
let descriptor = Descriptor::parse(bytes);
assert!(!descriptor.is_multi_axis_controller());
assert_eq!(descriptor.layout(), Layout::Split);
}
}
#[test]
fn four_byte_usages_carry_their_own_page() {
// Usage (4 bytes) 0x0007_0030 is on page 7, not an axis.
let descriptor = Descriptor::parse(&[0x05, 0x01, 0x09, 0x08, 0xA1, 0x01, 0x85, 0x01, 0x0B, 0x30, 0x00, 0x07, 0x00, 0x81, 0x06, 0xC0]);
assert!(descriptor.is_multi_axis_controller());
assert_eq!(descriptor.motion_report_axes, 0);
}
#[test]
fn truncated_and_hostile_descriptors_never_panic() {
for end in 0..=COMBINED.len() {
let _ = Descriptor::parse(&COMBINED[..end]);
}
// Long item with a length running past the end, huge usage ranges, deep nesting.
let _ = Descriptor::parse(&[0xFE, 0xFF, 0x00, 0x01]);
let _ = Descriptor::parse(&[0x05, 0x01, 0x1B, 0, 0, 0, 0, 0x2B, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0x06]);
let _ = Descriptor::parse(&[0xC0; 64]);
let _ = Descriptor::parse(&[0xA1; 64]);
let mut state = 0x2545_F491_4F6C_DD1D_u64;
for _ in 0..200 {
let bytes: Vec<u8> = (0..64)
.map(|_| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state.to_le_bytes()[0]
})
.collect();
let _ = Descriptor::parse(&bytes);
}
}
}
-57
View File
@@ -1,57 +0,0 @@
//! Finding a 3D mouse on Linux. The kernel exposes every HID interface as `/dev/hidrawN`, with
//! its identity and report descriptor under `/sys/class/hidraw/hidrawN/device/`. Reading both is
//! plain file I/O, so no udev or hidapi library is needed.
use std::path::{Path, PathBuf};
use super::hid_descriptor::{Descriptor, Layout};
const SYSFS: &str = "/sys/class/hidraw";
const DEV: &str = "/dev";
pub struct Device {
pub path: PathBuf,
pub layout: Layout,
}
/// The vendor id from `HID_ID=<bus>:<vendor>:<product>` (all hexadecimal) in a sysfs `uevent`.
fn vendor_id(uevent: &str) -> Option<u16> {
let id = uevent.lines().find_map(|line| line.strip_prefix("HID_ID="))?;
let vendor = id.split(':').nth(1)?;
u32::from_str_radix(vendor.trim(), 16).ok().and_then(|vendor| u16::try_from(vendor).ok())
}
/// The first hidraw interface that is a 3D mouse's multi-axis controller.
pub fn find() -> Option<Device> {
let mut names: Vec<_> = std::fs::read_dir(SYSFS).ok()?.filter_map(Result::ok).map(|entry| entry.file_name()).collect();
names.sort();
names.into_iter().find_map(|name| probe(&Path::new(SYSFS).join(&name), Path::new(DEV).join(&name)))
}
fn probe(sysfs: &Path, path: PathBuf) -> Option<Device> {
let uevent = std::fs::read_to_string(sysfs.join("device/uevent")).ok()?;
if !super::is_3d_mouse_vendor(vendor_id(&uevent)?) {
return None;
}
let descriptor = Descriptor::parse(&std::fs::read(sysfs.join("device/report_descriptor")).ok()?);
descriptor.is_multi_axis_controller().then(|| Device { path, layout: descriptor.layout() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_the_vendor_from_the_hid_id() {
let uevent = "DRIVER=hid-generic\nHID_ID=0003:0000256F:0000C631\nHID_NAME=Some Device\n";
assert_eq!(vendor_id(uevent), Some(0x256f));
assert_eq!(vendor_id("HID_ID=0003:0000046D:0000C077"), Some(0x046d));
}
#[test]
fn malformed_ids_are_rejected() {
for uevent in ["", "HID_NAME=x", "HID_ID=", "HID_ID=0003", "HID_ID=0003:zzzz:0001", "HID_ID=0003:FFFFFFFF:0001"] {
assert_eq!(vendor_id(uevent), None, "{uevent:?}");
}
}
}
-325
View File
@@ -1,325 +0,0 @@
//! Getting the window open, whatever state the settings folder or the graphics device is in
//! (docs/book/src/guide/troubleshooting.md).
//!
//! - **Settings that don't read** (`app.ron`, `graphics.json`): moved aside as
//! `<name>.bad-<time>` and the app starts with defaults, instead of a newer or damaged file
//! being dropped silently or stopping the start.
//! - **Launches that die before the window is up**: a marker file counts them; after two in a
//! row the next launch starts in safe mode by itself.
//! - **Safe mode** (`--safe-mode` or `SOLIDCRAFT_SAFE_MODE=1`): default settings (the saved ones
//! are left untouched), no recovery offer, the software renderer and CPU meshing.
//! - **No usable graphics device**: one retry on the software renderer
//! (`SOLIDCRAFT_RENDERER=cpu`), then a message box saying what failed and what to try.
use std::path::{Path, PathBuf};
/// The environment variable that starts safe mode (any value but `0` or empty).
pub const SAFE_MODE_ENV: &str = "SOLIDCRAFT_SAFE_MODE";
/// `SOLIDCRAFT_RENDERER=cpu` picks the software renderer (WARP, lavapipe, llvmpipe).
pub const RENDERER_ENV: &str = "SOLIDCRAFT_RENDERER";
/// Consecutive launches that died before the window was up, after which safe mode starts.
pub const FAILED_STARTS_FOR_SAFE_MODE: u32 = 2;
const MARKER: &str = "starting";
/// Whether an environment value switches something on.
pub fn env_on(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
}
/// Whether `SOLIDCRAFT_RENDERER` asks for the software renderer.
pub fn cpu_renderer_env(v: Option<&str>) -> bool {
v.map(str::trim).is_some_and(|v| v.eq_ignore_ascii_case("cpu") || v.eq_ignore_ascii_case("software"))
}
fn unix_now() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
/// Move `path` aside as `<name>.bad-<time>` (kept for a bug report); returns where it went.
pub fn back_up(path: &Path) -> std::io::Result<PathBuf> {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "settings".into());
let mut to = path.with_file_name(format!("{name}.bad-{}", unix_now()));
let mut n = 1;
while to.exists() && n < 100 {
to = path.with_file_name(format!("{name}.bad-{}-{n}", unix_now()));
n += 1;
}
std::fs::rename(path, &to)?;
Ok(to)
}
/// Whether eframe's `app.ron` reads, with the preferences inside it.
fn app_ron_reads(text: &str, prefs_keys: &[&str]) -> bool {
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return false };
prefs_keys.iter().filter_map(|k| kv.get(*k)).all(|p| serde_json::from_str::<serde_json::Value>(p).is_ok_and(|v| v.is_object()))
}
/// Check the settings files in `dir` before anything reads them: one that doesn't read is moved
/// aside (the app then starts with defaults for it). Returns a line per file moved.
pub fn check_settings(dir: &Path, prefs_keys: &[&str]) -> Vec<String> {
let mut notes = Vec::new();
let mut check = |name: &str, reads: &dyn Fn(&str) -> bool| {
let path = dir.join(name);
let Ok(bytes) = std::fs::read(&path) else { return };
let ok = std::str::from_utf8(&bytes).is_ok_and(|t| t.trim().is_empty() || reads(t));
if ok {
return;
}
match back_up(&path) {
Ok(to) => {
log::warn!("settings: {} did not read; moved to {}", path.display(), to.display());
notes.push(format!("{name} could not be read and was moved to {}; defaults are used.", to.display()));
}
Err(e) => log::warn!("settings: {} did not read and could not be moved: {e}", path.display()),
}
};
check("app.ron", &|t| app_ron_reads(t, prefs_keys));
check("graphics.json", &|t| serde_json::from_str::<serde_json::Value>(t).is_ok_and(|v| v.is_object()));
notes
}
/// A launch begins: count it in the marker in `dir`. Returns how many launches before this one
/// died before their window was up.
pub fn begin(dir: &Path) -> u32 {
let marker = dir.join(MARKER);
let before = std::fs::read_to_string(&marker).ok().and_then(|s| s.trim().parse::<u32>().ok()).unwrap_or(0).min(1000);
if std::fs::create_dir_all(dir).is_ok() {
let _ = std::fs::write(&marker, (before + 1).to_string());
}
before
}
/// The window is up: this launch did not fail to start.
pub fn started(dir: &Path) {
let _ = std::fs::remove_file(dir.join(MARKER));
}
/// The software adapter among `adapters` (WARP on Windows, lavapipe or llvmpipe elsewhere), else
/// the first one that can draw to `surface`.
pub fn pick_cpu_adapter(adapters: &[eframe::wgpu::Adapter], surface: Option<&eframe::wgpu::Surface<'_>>) -> Result<eframe::wgpu::Adapter, String> {
let fits = |a: &&eframe::wgpu::Adapter| surface.is_none_or(|s| a.is_surface_supported(s));
adapters
.iter()
.filter(fits)
.find(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu)
.or_else(|| adapters.iter().find(fits))
.cloned()
.ok_or_else(|| {
let names: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().backend)).collect();
format!("no graphics adapter can draw the window (found: {})", if names.is_empty() { "none".into() } else { names.join(", ") })
})
}
/// `WGPU_BACKEND`: when set, wgpu picks the backends from it and the order below is not used.
pub const WGPU_BACKEND_ENV: &str = "WGPU_BACKEND";
/// The graphics backends to start with, or `None` to leave it to wgpu (`WGPU_BACKEND`, or all
/// the backends it was built with).
///
/// On Windows, D3D12 comes first, then Vulkan, then OpenGL, and only the first one with a
/// hardware adapter is started at all. wgpu would otherwise start Vulkan, D3D12 and OpenGL
/// together and prefer Vulkan, and starting Vulkan loads every installed Vulkan driver: Intel's
/// (`igvk64.dll`) crashed SolidCraft at startup that way, inside the driver, before anything
/// could be reported. D3D12 is Windows' own graphics API, the one its drivers are tested with.
/// `has_hardware(backends)` says whether those backends have an adapter that isn't software
/// (it is only asked about the backends being tried, one at a time).
///
/// - `WGPU_BACKEND` set (`env_set`): `None`, whatever it says wins.
/// - The software renderer (`cpu`, safe mode): D3D12 alone on Windows (WARP); `None` elsewhere.
/// - `preference` (`graphics.json`'s `backend`: `dx12`, `vulkan`, `gl` or a comma list; `auto`
/// or empty for the order above): those backends, on any system.
/// - Elsewhere than Windows: `None`.
/// - No hardware adapter at all: D3D12 (WARP, Windows' software renderer).
pub fn choose_backends(
windows: bool,
env_set: bool,
cpu: bool,
preference: Option<&str>,
mut has_hardware: impl FnMut(eframe::wgpu::Backends) -> bool,
) -> Option<eframe::wgpu::Backends> {
use eframe::wgpu::Backends;
if env_set {
return None;
}
if cpu {
return windows.then_some(Backends::DX12);
}
if let Some(p) = preference.map(str::trim).filter(|p| !p.is_empty() && !p.eq_ignore_ascii_case("auto")) {
let chosen = Backends::from_comma_list(p);
if !chosen.is_empty() {
return Some(chosen);
}
log::warn!("graphics: unknown backend preference `{p}`; using the default order");
}
if !windows {
return None;
}
Some(WINDOWS_ORDER.into_iter().find(|b| has_hardware(*b)).unwrap_or(Backends::DX12))
}
/// The order backends are tried in on Windows.
pub const WINDOWS_ORDER: [eframe::wgpu::Backends; 3] = [eframe::wgpu::Backends::DX12, eframe::wgpu::Backends::VULKAN, eframe::wgpu::Backends::GL];
/// Whether `backends` (started on their own, nothing else) have a hardware adapter.
pub fn has_hardware_adapter(backends: eframe::wgpu::Backends) -> bool {
let mut desc = eframe::wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = backends;
desc.flags = eframe::wgpu::InstanceFlags::from_build_config().with_env();
let instance = eframe::wgpu::Instance::new(desc);
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
let found: Vec<String> = adapters.iter().map(|a| format!("{} ({:?})", a.get_info().name, a.get_info().device_type)).collect();
log::info!("graphics: {backends:?} adapters: {found:?}");
adapters.iter().any(|a| a.get_info().device_type != eframe::wgpu::DeviceType::Cpu)
}
/// The message shown when no window could be opened at all.
pub fn no_window_message(error: &str, settings: Option<&Path>) -> String {
let folder = settings.map(|d| d.display().to_string()).unwrap_or_else(|| "the SolidCraft settings folder".into());
format!(
"SolidCraft could not open its window.\n\n{error}\n\n\
The software renderer was tried too. Things to try:\n\
- Install or update the graphics driver (on Windows, from the GPU maker's site rather than Windows Update).\n\
- Over Remote Desktop or in a virtual machine, enable 3D acceleration or run it on the machine itself.\n\
- Start it once with `solidcraft --safe-mode`.\n\
- Rename the settings folder: {folder}\n\n\
More: https://bherbruck.github.io/solidcraft/guide/troubleshooting.html"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn switches_read_from_the_environment() {
assert!(env_on(Some("1")) && env_on(Some("yes")) && !env_on(Some("0")) && !env_on(Some(" ")) && !env_on(None) && !env_on(Some("false")));
assert!(cpu_renderer_env(Some("cpu")) && cpu_renderer_env(Some("Software")) && !cpu_renderer_env(Some("gpu")) && !cpu_renderer_env(None));
}
#[test]
fn settings_that_do_not_read_are_moved_aside_and_good_ones_stay() {
let keys = ["solidcraft.prefs", "solvecraft.prefs"];
let d = scratch("check");
// Good files (and an empty one) stay where they are.
std::fs::write(d.join("app.ron"), r#"{"solidcraft.prefs": "{\"grid\":true}", "egui": "(anything)"}"#).unwrap();
std::fs::write(d.join("graphics.json"), r#"{"msaa":4}"#).unwrap();
assert!(check_settings(&d, &keys).is_empty());
assert!(d.join("app.ron").is_file() && d.join("graphics.json").is_file());
// Damaged RON, preferences that aren't JSON, a truncated graphics.json, non-UTF-8 bytes.
for (name, text) in [
("app.ron", b"{\"solidcraft.prefs\": \"{\\\"gr".as_slice()),
("app.ron", br#"{"solvecraft.prefs": "not json"}"#.as_slice()),
("graphics.json", br#"{"msaa":"#.as_slice()),
("graphics.json", b"\xff\xfe\x00".as_slice()),
] {
std::fs::write(d.join(name), text).unwrap();
let notes = check_settings(&d, &keys);
assert_eq!(notes.len(), 1, "{name}: {notes:?}");
assert!(!d.join(name).exists(), "{name} moved");
let kept: Vec<_> =
std::fs::read_dir(&d).unwrap().flatten().filter(|e| e.file_name().to_string_lossy().starts_with(&format!("{name}.bad-"))).collect();
assert!(kept.iter().any(|e| std::fs::read(e.path()).unwrap() == text), "{name}: the bad file is kept");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn launches_that_never_got_a_window_are_counted_until_one_does() {
let d = scratch("marker");
assert_eq!(begin(&d), 0);
assert_eq!(begin(&d), 1);
assert_eq!(begin(&d), FAILED_STARTS_FOR_SAFE_MODE);
started(&d);
assert_eq!(begin(&d), 0);
// A damaged marker counts as none.
std::fs::write(d.join(MARKER), "zz").unwrap();
assert_eq!(begin(&d), 0);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn windows_starts_d3d12_first_and_vulkan_only_when_d3d12_has_no_gpu() {
use eframe::wgpu::Backends;
let probe = |with: &'static [Backends]| {
move |b: Backends| {
assert!(b == Backends::DX12 || b == Backends::VULKAN || b == Backends::GL, "one backend at a time: {b:?}");
with.contains(&b)
}
};
// D3D12 works: Vulkan and OpenGL are never even asked about (starting Vulkan loads
// its drivers, which is what crashed).
let mut asked = Vec::new();
let chosen = choose_backends(true, false, false, None, |b| {
asked.push(b);
b == Backends::DX12
});
assert_eq!(chosen, Some(Backends::DX12));
assert_eq!(asked, [Backends::DX12]);
// Everything works: still D3D12 alone.
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::DX12, Backends::VULKAN, Backends::GL])), Some(Backends::DX12));
// No D3D12 hardware adapter: Vulkan, else OpenGL, else D3D12's software adapter (WARP).
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::VULKAN, Backends::GL])), Some(Backends::VULKAN));
assert_eq!(choose_backends(true, false, false, None, probe(&[Backends::GL])), Some(Backends::GL));
assert_eq!(choose_backends(true, false, false, None, probe(&[])), Some(Backends::DX12));
// `auto` and blanks are the default order.
assert_eq!(choose_backends(true, false, false, Some(" auto "), probe(&[Backends::DX12])), Some(Backends::DX12));
assert_eq!(choose_backends(true, false, false, Some(""), probe(&[Backends::GL])), Some(Backends::GL));
}
#[test]
fn wgpu_backend_the_preference_and_the_software_renderer_override_the_order() {
use eframe::wgpu::Backends;
let never = |b: Backends| -> bool { panic!("no probing expected, asked about {b:?}") };
// WGPU_BACKEND: wgpu reads it itself, nothing is probed.
assert_eq!(choose_backends(true, true, false, Some("gl"), never), None);
assert_eq!(choose_backends(true, true, true, None, never), None);
// The software renderer (safe mode, the retry): WARP through D3D12 alone, never Vulkan.
assert_eq!(choose_backends(true, false, true, Some("vulkan"), never), Some(Backends::DX12));
assert_eq!(choose_backends(false, false, true, None, never), None);
// The preference, on any system, without probing.
assert_eq!(choose_backends(true, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
assert_eq!(choose_backends(true, false, false, Some("GL"), never), Some(Backends::GL));
assert_eq!(choose_backends(true, false, false, Some("dx12,vulkan"), never), Some(Backends::DX12 | Backends::VULKAN));
assert_eq!(choose_backends(false, false, false, Some("vulkan"), never), Some(Backends::VULKAN));
// An unknown preference is the default order.
assert_eq!(choose_backends(true, false, false, Some("glide"), |b| b == Backends::DX12), Some(Backends::DX12));
// Elsewhere: wgpu's own choice.
assert_eq!(choose_backends(false, false, false, None, never), None);
assert_eq!(choose_backends(false, false, false, Some("auto"), never), None);
}
#[test]
fn probing_a_backend_alone_never_panics() {
// Whatever this machine has (often nothing): an answer, not a crash.
for b in WINDOWS_ORDER {
let _ = has_hardware_adapter(b);
}
}
#[test]
fn the_message_names_the_settings_folder_and_safe_mode() {
let m = no_window_message("no adapter", Some(Path::new("/x/SolidCraft")));
assert!(m.contains("no adapter") && m.contains("/x/SolidCraft") && m.contains("--safe-mode"));
}
#[test]
fn the_software_adapter_is_preferred_and_its_absence_is_an_error_not_a_panic() {
assert!(pick_cpu_adapter(&[], None).unwrap_err().contains("none"));
let instance = eframe::wgpu::Instance::new(eframe::wgpu::InstanceDescriptor::new_without_display_handle());
let adapters = pollster::block_on(instance.enumerate_adapters(eframe::wgpu::Backends::all()));
if adapters.is_empty() {
return;
}
let picked = pick_cpu_adapter(&adapters, None).unwrap();
if adapters.iter().any(|a| a.get_info().device_type == eframe::wgpu::DeviceType::Cpu) {
assert_eq!(picked.get_info().device_type, eframe::wgpu::DeviceType::Cpu);
}
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "solvecraft-cli"
description = "SolveCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "solvecraft-cli"
path = "src/main.rs"
[dependencies]
solvecraft-engine = { workspace = true }
solvecraft-mcp = { workspace = true }
serde_json = { workspace = true }
@@ -1,4 +1,4 @@
//! `solidcraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
//!
//! Each seed runs in its own process (`fuzz-one`) with a time limit, so a hang or a hard crash
//! is recorded like any other failure.
@@ -9,8 +9,8 @@ use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use solidcraft_engine::fuzz;
use solidcraft_engine::fuzz::boolean_case;
use solvecraft_engine::fuzz;
use solvecraft_engine::fuzz::boolean_case;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).and_then(|v| v.parse().ok()).unwrap_or(default)
@@ -33,8 +33,6 @@ pub fn one(args: &[String]) -> Result<(), String> {
"steps": run.steps.iter().map(|s| s.kind).collect::<Vec<_>>(),
"unsupported": run.unsupported,
"rejected": run.rejected,
"boolean_fallbacks": run.boolean_fallbacks,
"boolean_reports_truncated": run.boolean_reports_truncated,
"panics": PANICS.swap(0, std::sync::atomic::Ordering::SeqCst),
});
if run.outcome.is_bug() {
@@ -45,7 +43,7 @@ pub fn one(args: &[String]) -> Result<(), String> {
design["minimal"] = json!({"outcome": m.outcome, "steps": m.steps.iter().map(|s| s.kind).collect::<Vec<_>>(), "script": m.script()});
}
}
let (a, b) = solidcraft_engine::fuzz::boolean_shapes(seed);
let (a, b) = solvecraft_engine::fuzz::boolean_shapes(seed);
eprintln!("boolean {seed} {} {}", serde_json::to_string(&a).unwrap_or_default(), serde_json::to_string(&b).unwrap_or_default());
let b = boolean_case(seed);
let bp = PANICS.swap(0, std::sync::atomic::Ordering::SeqCst);
@@ -66,8 +64,6 @@ struct Job {
pub fn run(args: &[String]) -> Result<(), String> {
let from = flag(args, "--from", 0u64);
let count = flag(args, "--count", 100u64);
let last = from.checked_add(count.saturating_sub(1)).ok_or("seed range exceeds u64::MAX: reduce --from or --count")?;
let mut seeds = (count > 0).then_some(from..=last).into_iter().flatten().peekable();
let jobs = flag(args, "--jobs", 4usize).clamp(1, 64);
let timeout = Duration::from_secs(flag(args, "--timeout", 120u64));
let steps = flag(args, "--steps", 8usize);
@@ -78,14 +74,14 @@ pub fn run(args: &[String]) -> Result<(), String> {
if let Some(d) = &out_dir {
std::fs::create_dir_all(d).map_err(|e| format!("{d}: {e}"))?;
}
let mut next = from;
let mut running: Vec<Job> = Vec::new();
let mut results: Vec<Value> = Vec::new();
let t0 = Instant::now();
while seeds.peek().is_some() || !running.is_empty() {
while running.len() < jobs {
let Some(seed) = seeds.next() else { break };
while next < from + count || !running.is_empty() {
while running.len() < jobs && next < from + count {
let mut cmd = Command::new(&exe);
cmd.args(["fuzz-one", &seed.to_string(), "--steps", &steps.to_string()]);
cmd.args(["fuzz-one", &next.to_string(), "--steps", &steps.to_string()]);
if minimise {
cmd.arg("--minimise");
}
@@ -100,7 +96,8 @@ pub fn run(args: &[String]) -> Result<(), String> {
}
});
}
running.push(Job { seed, child, started: Instant::now(), err: rx, last: String::new() });
running.push(Job { seed: next, child, started: Instant::now(), err: rx, last: String::new() });
next += 1;
}
std::thread::sleep(Duration::from_millis(50));
let mut i = 0;
@@ -181,23 +178,9 @@ fn summarise(results: &[Value], took: Duration) -> String {
let mut boolean: BTreeMap<String, usize> = BTreeMap::new();
let mut at_step: BTreeMap<String, usize> = BTreeMap::new();
let mut unsupported: BTreeMap<String, usize> = BTreeMap::new();
let mut fallbacks: BTreeMap<String, [u64; 2]> = BTreeMap::new();
let mut truncated = [0usize; 2];
let mut steps = 0;
let (mut dp, mut bp) = (0, 0);
for v in results {
for (i, group) in ["design", "boolean"].into_iter().enumerate() {
if v[group]["boolean_reports_truncated"].as_bool().unwrap_or(false) {
truncated[i] += 1;
}
if let Some(counts) = v[group]["boolean_fallbacks"].as_object() {
for (strategy, count) in counts {
if let Some(total) = fallbacks.entry(strategy.clone()).or_default().get_mut(i) {
*total = total.saturating_add(count.as_u64().unwrap_or(0));
}
}
}
}
dp += usize::from(v["design"]["panics"].as_u64().unwrap_or(0) > 0);
bp += usize::from(v["boolean_panics"].as_u64().unwrap_or(0) > 0);
let k = if v.get("timeout").is_some() {
@@ -236,28 +219,5 @@ fn summarise(results: &[Value], took: Duration) -> String {
for (k, c) in &boolean {
s += &format!("- {k}: {}\n", pct(*c));
}
s += "\nSuccessful Boolean retries (events, including nested operations):\n";
for (strategy, [designs, checks]) in &fallbacks {
s += &format!("- {strategy}: designs {designs}, boolean checks {checks}\n");
}
s += &format!("- Capped reports: designs {}, boolean checks {}\n", truncated[0], truncated[1]);
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_counts_retry_events_separately_and_marks_incomplete_logs() {
let results = [
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":2}},"boolean":{"outcome":"ok","boolean_fallbacks":{"shifted_operands":3},"boolean_reports_truncated":true}}),
json!({"design":{"outcome":{"kind":"ok"},"boolean_fallbacks":{"shifted_operands":1}},"boolean":{"outcome":"ok"}}),
json!({"timeout":90}),
];
let text = summarise(&results, Duration::ZERO);
assert!(text.contains("shifted_operands: designs 3, boolean checks 3"), "{text}");
assert!(text.contains("Capped reports: designs 0, boolean checks 1"), "{text}");
assert!(text.contains("timeout: 1 (33.3 %)"), "{text}");
}
}
+353
View File
@@ -0,0 +1,353 @@
//! SolveCraft headless command line.
//!
//! ```text
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
//! solvecraft-cli exec <command> [json-params] run one command on an empty design
//! solvecraft-cli commands the command registry as JSON
//! solvecraft-cli recipe <recipe.json> translate an oracle recipe to a script
//! solvecraft-cli oracle <case-dir>... replay recipes and compare with measure.json
//! solvecraft-cli step-corpus <case-dir>... import part.step and compare with measure.json
//! solvecraft-cli mcp [--in design|script] [--connect HOST:PORT] MCP server on stdio (docs/mcp.md)
//! solvecraft-cli licences SolveCraft's licence and the third-party licences
//! solvecraft-cli icon --out F.png|F.ico [--size N] the app icon (packaging; drawn, not a file)
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod fuzz;
mod oracle;
mod recipe;
mod seams;
use std::process::ExitCode;
use serde_json::{Value, json};
use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
const USAGE: &str = "usage:
solvecraft-cli run <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
solvecraft-cli exec <command> [json-params]
solvecraft-cli commands
solvecraft-cli recipe <recipe.json>
solvecraft-cli oracle <case-dir>... [--json]
solvecraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE]
solvecraft-cli step-corpus <case-dir>... [--json] [--iges]
solvecraft-cli mcp [--in design.solvecraft|script.json] [--connect 127.0.0.1:PORT]
solvecraft-cli licences
solvecraft-cli icon --out icon.png [--size 256] | --out icon.ico
";
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("run") => cmd_run(&args[1..]),
Some("eval" | "inspect") => cmd_eval(&args[1..]),
Some("snapshot") => cmd_snapshot(&args[1..]),
Some("exec") => cmd_exec(&args[1..]),
Some("commands") => {
let mut s = Session::default();
s.execute("engine.commands", &json!({})).map(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string())
}
Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| {
let v = read_json(p)?;
recipe::to_script(&v).map(|s| println!("{}", pretty(&s)))
}),
Some("oracle") => oracle::run(&args[1..]),
Some("step-corpus") => oracle::step_corpus(&args[1..]),
Some("fuzz") => fuzz::run(&args[1..]),
Some("fuzz-one") => fuzz::one(&args[1..]),
Some("mcp") => cmd_mcp(&args[1..]),
Some("icon") => cmd_icon(&args[1..]),
Some("licences" | "licenses") => {
// Written, not print!ed: a closed pipe (`| head`) is not a crash.
use std::io::Write as _;
let _ = std::io::stdout().lock().write_all(solvecraft_engine::licences::all().as_bytes());
Ok(())
}
// Hidden, for the kill-mid-save test: save a large design over and over.
Some("save-stress") => cmd_save_stress(&args[1..]),
// Hidden: time recomputes after parameter edits (docs/perf.md).
Some("bench-edit") => cmd_bench_edit(&args[1..]),
Some("--version" | "-V") => {
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
_ => Err(USAGE.to_string()),
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn pretty(v: &Value) -> String {
serde_json::to_string_pretty(v).unwrap_or_default()
}
pub fn read_json(path: &str) -> Result<Value, String> {
let s = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
serde_json::from_str(&s).map_err(|e| format!("{path}: {e}"))
}
/// A session from a design file, a STEP/3MF/STL file, a command script, or an oracle recipe.
pub fn load(path: &str) -> Result<Session, String> {
let mut s = Session::default();
if solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
return Ok(s);
}
let v = read_json(path)?;
if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) {
s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?;
} else if v.get("features").is_some() && v.get("commands").is_none() {
let script = recipe::to_script(&v)?;
s.run_script(&script).map_err(|e| e.to_string())?;
} else {
s.run_script(&v).map_err(|e| e.to_string())?;
}
Ok(s)
}
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
}
fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> {
args.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect()
}
/// The first argument that is not an option or an option's value.
fn positional(args: &[String]) -> Option<&String> {
let mut i = 0;
while let Some(a) = args.get(i) {
if a.starts_with("--") {
i += if matches!(a.as_str(), "--quiet" | "--results") { 1 } else { 2 };
continue;
}
return Some(a);
}
None
}
fn cmd_run(args: &[String]) -> Result<(), String> {
let base = flag(args, "--in");
let path = positional(args);
if path.is_none() && base.is_none() {
return Err(USAGE.to_string());
}
// `--in` starts from a design or STEP file; the positional script then runs on top of it.
let mut s = match base {
Some(b) => load(b)?,
None => Session::default(),
};
if args.iter().any(|a| a == "--results") {
// Print every command's result (scripts only).
let v = read_json(path.ok_or("--results needs a script")?)?;
let r = s.run_script(&v).map_err(|e| e.to_string())?;
println!("{}", pretty(&Value::Array(r)));
return Ok(());
}
match (base, path) {
(Some(_), Some(p)) => {
let v = read_json(p)?;
s.run_script(&v).map_err(|e| e.to_string())?;
}
(None, Some(p)) => s = load(p)?,
_ => {}
}
for out in flags(args, "--out") {
s.execute("file.export", &json!({"path": out})).map_err(|e| e.to_string())?;
}
if let Some(p) = flag(args, "--save") {
s.execute("file.save_as", &json!({"path": p})).map_err(|e| e.to_string())?;
}
if !args.iter().any(|a| a == "--quiet") {
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
println!("{}", pretty(&m));
}
Ok(())
}
fn cmd_eval(args: &[String]) -> Result<(), String> {
let path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
let mut s = load(path)?;
let m = s.execute("inspect.measure", &json!({})).map_err(|e| e.to_string())?;
let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?;
let out = json!({
"body_count": m["body_count"],
"bodies": m["bodies"],
"total": m["total"],
"timeline": d["timeline"],
"params": d["params"],
"sketches": d["sketches"],
});
println!("{}", pretty(&out));
Ok(())
}
fn cmd_snapshot(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or_else(|| USAGE.to_string())?;
let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?;
let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize);
let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize);
let s = load(path)?;
let mut cam = solvecraft_engine::view::home_camera(&s);
if let Some(v) = flag(args, "--view") {
cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?);
}
let scene = solvecraft_engine::view::scene(&s, &cam);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()})));
Ok(())
}
/// The app icon: a blue block with a bore, drawn by the CPU renderer on a transparent background
/// (so packages need no committed image). `.ico` holds the Windows sizes as PNGs.
fn cmd_icon(args: &[String]) -> Result<(), String> {
let out = flag(args, "--out").ok_or("icon needs --out FILE.png or FILE.ico")?;
let size: usize = flag(args, "--size").and_then(|x| x.parse().ok()).unwrap_or(256).clamp(8, 2048);
let bytes = if out.to_ascii_lowercase().ends_with(".ico") {
let pngs: Vec<(usize, Vec<u8>)> =
[16, 20, 24, 32, 40, 48, 64, 128, 256].into_iter().map(|s| icon_png(s).map(|p| (s, p))).collect::<Result<_, _>>()?;
ico(&pngs)?
} else {
icon_png(size)?
};
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
println!("{}", pretty(&json!({"path": out, "bytes": bytes.len()})));
Ok(())
}
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
use solvecraft_engine::render::{Rgb, Scene, SceneLine, SceneMesh, png, render_transparent};
let mut s = Session::default();
for (cmd, p) in [
("solid.box", json!({"length": 20, "width": 20, "height": 20, "center": [0, 0, 0]})),
("solid.cylinder", json!({"diameter": 9, "height": 30, "base": [0, 0, -15], "operation": "cut"})),
] {
s.execute(cmd, &p).map_err(|e| format!("icon: {e}"))?;
}
let mut cam = solvecraft_engine::view::home_camera(&s);
cam.distance *= 0.95;
let mut scene = Scene { radius: 60.0, ..Default::default() };
let px = size as f64 / 256.0;
for body in &s.world_state().bodies {
let mesh = body.mesh();
scene.meshes.push(SceneMesh { mesh: std::sync::Arc::clone(&mesh), color: Rgb(90, 160, 240), face_colors: Vec::new() });
for (i, e) in mesh.edges.iter().enumerate() {
if !mesh.seams.get(i).copied().unwrap_or(false) {
scene.lines.push(SceneLine { points: e.clone(), color: Rgb(24, 52, 96), width: (3.0 * px).max(0.8), on_top: false });
}
}
}
let c = render_transparent(&mut scene, &cam, size, size);
png(c.w, c.h, c.rgba).ok_or_else(|| "icon: PNG encoding failed".to_string())
}
/// A Windows icon file holding PNG images (Vista and later read these).
fn ico(images: &[(usize, Vec<u8>)]) -> Result<Vec<u8>, String> {
let n = u16::try_from(images.len()).map_err(|_| "too many icon sizes")?;
let mut out = Vec::new();
out.extend_from_slice(&[0, 0, 1, 0]);
out.extend_from_slice(&n.to_le_bytes());
let mut offset = 6 + 16 * images.len();
for (size, data) in images {
let dim = if *size >= 256 { 0 } else { *size as u8 };
out.extend_from_slice(&[dim, dim, 0, 0, 1, 0, 32, 0]);
out.extend_from_slice(&u32::try_from(data.len()).map_err(|_| "icon too large")?.to_le_bytes());
out.extend_from_slice(&u32::try_from(offset).map_err(|_| "icon too large")?.to_le_bytes());
offset += data.len();
}
for (_, data) in images {
out.extend_from_slice(data);
}
Ok(out)
}
fn cmd_exec(args: &[String]) -> Result<(), String> {
let id = args.first().ok_or_else(|| USAGE.to_string())?;
let p: Value = match args.get(1) {
Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?,
None => json!({}),
};
let mut s = Session::default();
let v = s.execute(id, &p).map_err(|e| e.to_string())?;
println!("{}", pretty(&v));
Ok(())
}
/// MCP server on stdio: headless (optionally starting from a design or script) or bridged to
/// a running app's control port. stdout carries only protocol messages.
fn cmd_mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn solvecraft_mcp::Backend> = match (flag(args, "--connect"), flag(args, "--in")) {
(Some(_), Some(_)) => return Err("mcp: use either --connect or --in, not both".into()),
(Some(addr), None) => Box::new(solvecraft_mcp::Remote::connect(addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?),
(None, Some(path)) => Box::new(solvecraft_mcp::Headless::new(load(path)?)),
(None, None) => Box::new(solvecraft_mcp::Headless::default()),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
}
/// `save-stress <path> <rounds>`: save a design of a few hundred kilobytes `rounds` times, its
/// parameter `save_round` set to the round number each time.
fn cmd_save_stress(args: &[String]) -> Result<(), String> {
let (Some(path), Some(rounds)) = (args.first(), args.get(1).and_then(|n| n.parse::<u64>().ok())) else {
return Err("usage: solvecraft-cli save-stress <path> <rounds>".into());
};
let mut s = Session::default();
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
name: format!("p{i}"),
expr: format!("{i} mm + 0.5 mm"),
unit: "mm".into(),
comment: "filler to make the file large".into(),
model: false,
});
s.doc_mut().params.extend(filler);
for r in 0..rounds {
s.doc_mut().set_param("save_round", &r.to_string(), None, None).map_err(|e| e.to_string())?;
s.execute("file.save", &json!({ "path": path })).map_err(|e| e.to_string())?;
}
Ok(())
}
/// `bench-edit <script.json|recipe.json> <param> <expr>`: build the design, then time a
/// parameter edit, setting it back, undo and redo (ms and features recomputed each).
fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
let (Some(path), Some(param), Some(expr)) = (args.first(), args.get(1), args.get(2)) else {
return Err("usage: solvecraft-cli bench-edit <script.json|recipe.json> <param> <expr>".into());
};
let v = read_json(path)?;
let script = if v.get("features").is_some() { recipe::to_script(&v)? } else { v };
let mut s = Session::default();
let t = std::time::Instant::now();
s.run_script(&script).map_err(|e| e.to_string())?;
let build = t.elapsed().as_secs_f64() * 1000.0;
let old = s.doc.all_param_exprs().into_iter().find(|(n, _, _)| n == param).map(|(_, e, _)| e).ok_or(format!("no parameter `{param}`"))?;
let mut out = vec![json!({"step": "build", "ms": build, "features": s.doc.features.len()})];
let mut time = |s: &mut Session, step: &str, id: &str, p: Value| -> Result<(), String> {
let t = std::time::Instant::now();
let r = s.execute(id, &p).map_err(|e| format!("{step}: {e}"))?;
let n = r.get("recomputed").cloned().unwrap_or_else(|| json!(s.model.last_recomputed));
out.push(json!({"step": step, "ms": t.elapsed().as_secs_f64() * 1000.0, "recomputed": n}));
Ok(())
};
let expr = expr.replace("{old}", &old);
time(&mut s, "edit", "parameters.change", json!({"name": param, "expression": expr}))?;
time(&mut s, "set back", "parameters.change", json!({"name": param, "expression": old}))?;
time(&mut s, "undo", "edit.undo", json!({}))?;
time(&mut s, "redo", "edit.redo", json!({}))?;
println!("{}", pretty(&json!(out)));
Ok(())
}
+277
View File
@@ -0,0 +1,277 @@
//! Replay oracle recipes and compare with Fusion's measurements; import oracle STEP files and
//! compare them with the same measurements.
use serde_json::{Value, json};
use crate::{read_json, recipe, seams};
/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4).
pub const REL_TOL: f64 = 1e-3;
/// Cases compared loosely (relative tolerance on volume and area, topology not compared), and why.
const APPROXIMATE: [(&str, f64, &str); 1] = [(
"49-helical-sweep",
5e-3,
"Fusion sweeps along a spline fitted through helix points; SolveCraft builds the true helix (a coil), so the size is compared within 0.5% and the topology not at all",
)];
fn check_case(dir: &str) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let rp = format!("{dir}/recipe.json");
let mp = format!("{dir}/measure.json");
let (recipe, want) = match (read_json(&rp), read_json(&mp)) {
(Ok(r), Ok(m)) => (r, m),
(Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}),
};
let script = match recipe::to_script(&recipe) {
Ok(s) => s,
Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}),
};
let mut s = solvecraft_engine::Session::default();
if let Err(e) = s.run_script(&script) {
return json!({"case": name, "pass": false, "error": e.to_string()});
}
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let mut checks = Vec::new();
let mut pass = true;
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
let ok = match (g, w) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
pass &= ok;
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
};
// Cases where Fusion's part is itself an approximation: compare size loosely, not topology.
let approx = APPROXIMATE.iter().find(|(c, _, _)| *c == name);
let tol = approx.map(|(_, t, _)| *t).unwrap_or(REL_TOL);
let within = |k: &str, rel: f64| match (got["total"][k].as_f64(), want["total"][k].as_f64()) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
let size_ok =
got["body_count"] == want["body_count"] && within("volume_mm3", tol) && (FUSION_AREA_OFF.contains(&name.as_str()) || within("area_mm2", tol));
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), tol);
let area_off = FUSION_AREA_OFF.contains(&name.as_str());
if !area_off {
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), tol);
}
let mut seam_note = None;
if approx.is_none() {
let exact = ["faces", "edges", "vertices"].iter().all(|k| got["total"][k].as_f64().is_some() && got["total"][k] == want["total"][k]);
let fusion_seams = want["bodies"]
.as_array()
.and_then(|bs| bs.iter().map(seams::normalised).try_fold((0, 0, 0), |a, n| n.map(|n| (a.0 + n.0, a.1 + n.1, a.2 + n.2))));
match fusion_seams {
// Size matches and only the topology differs: compare with Fusion's seam splits merged.
Some((f, e, v)) if size_ok && !exact => {
let raw: Vec<String> = ["faces", "edges", "vertices"]
.iter()
.map(|k| {
let ours: i64 = got["bodies"].as_array().into_iter().flatten().filter_map(|b| b["kernel"][k].as_i64()).sum();
format!("{k} {ours}/{}", want["total"][k])
})
.collect();
seam_note = Some(format!(
"topology compared seam-normalised: Fusion's {}/{}/{} faces/edges/vertices become {f}/{e}/{v} with closed-surface patches counted once and seam edges and vertices dropped (raw SolveCraft/Fusion: {})",
want["total"]["faces"],
want["total"]["edges"],
want["total"]["vertices"],
raw.join(", ")
));
num("faces (seam-normalised)", got["total"]["faces"].as_f64(), Some(f as f64), 0.0);
num("edges (seam-normalised)", got["total"]["edges"].as_f64(), Some(e as f64), 0.0);
num("vertices (seam-normalised)", got["total"]["vertices"].as_f64(), Some(v as f64), 0.0);
}
_ => {
num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0);
num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0);
num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0);
}
}
}
// Our STEP export of the rebuilt part: well formed, and read back with no warnings and the
// same volume.
let (ok, note) = step_export_check(&s, got["total"]["volume_mm3"].as_f64().unwrap_or(0.0));
pass &= ok;
checks.push(json!({"check": "step_export", "got": note, "want": "valid, re-imports cleanly", "ok": ok}));
let mut out = json!({"case": name, "pass": pass, "checks": checks});
if let Some((_, _, why)) = approx {
out["note"] = json!(why);
}
if let Some(n) = seam_note {
out["note"] = json!(n);
}
if area_off {
out["note"] = json!(
"area not compared: Fusion reports this tight sweep's horn torus face at three times its area (π²·R·r quarter-turn value; 740 mm² against 246.7 by Pappus); volume and topology are compared"
);
}
let effective: Vec<String> = recipe["features"]
.as_array()
.into_iter()
.flatten()
.filter(|f| f.get("fusion_effective_extent").is_some())
.map(|f| f["name"].as_str().unwrap_or("?").to_string())
.collect();
if !effective.is_empty() {
out["note"] = json!(format!(
"known Fusion difference: {} replayed with the extent Fusion actually used (its through-all is sized as 1.1 × the farthest vertex, so curved tops stop it short; SolveCraft's through-all goes truly through)",
effective.join(", ")
));
}
out
}
/// Export the session's bodies as STEP, validate the file and import it again.
fn step_export_check(s: &solvecraft_engine::Session, volume: f64) -> (bool, String) {
let st = s.model.state();
let bytes = match solvecraft_engine::io::export(&st, &[], solvecraft_engine::io::Format::Step, "oracle") {
Ok(b) => b,
Err(e) => return (false, format!("export: {e}")),
};
let text = String::from_utf8_lossy(&bytes);
if let Err(e) = solvecraft_engine::kernel::step_validate(&text) {
return (false, format!("invalid: {e}"));
}
match solvecraft_engine::kernel::step_orientation_errors(&text) {
Ok(errs) if !errs.is_empty() => return (false, format!("faces inside out: {}", errs.join("; "))),
Err(e) => return (false, format!("orientation check: {e}")),
Ok(_) => {}
}
match solvecraft_engine::kernel::step_import(&text) {
Ok(imp) if !imp.warnings.is_empty() => (false, format!("warnings: {}", imp.warnings.join("; "))),
Ok(imp) if imp.bodies.len() != st.bodies.len() => (false, format!("{} bodies back of {}", imp.bodies.len(), st.bodies.len())),
Ok(imp) => {
let m: Vec<_> = imp.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
let m0: Vec<_> = st.bodies.iter().filter_map(|b| solvecraft_engine::kernel::measure(&b.body).ok()).collect();
let v: f64 = m.iter().map(|x| x.volume).sum();
let (a, a0): (f64, f64) = (m.iter().map(|x| x.area).sum(), m0.iter().map(|x| x.area).sum());
let faces: usize = imp.bodies.iter().map(|b| b.file_faces).sum();
let faces0: usize = m0.iter().map(|x| x.merged.faces).sum();
let close = |x: f64, y: f64| (x - y).abs() <= REL_TOL * y.abs().max(1.0);
let faces_raw: usize = st.bodies.iter().map(|b| b.body.face_count()).sum();
// (Fewer is fine: pieces of one plane, cylinder, cone, sphere or torus are written as one face.)
let ok = close(v, volume) && close(a, a0) && faces <= faces_raw;
(ok, format!("re-imported volume {v:.3} (was {volume:.3}), area {a:.3} (was {a0:.3}), faces {faces} (was {faces0})"))
}
Err(e) => (false, format!("import: {e}")),
}
}
/// Cases whose Fusion area is not the geometric one: Fusion reports the horn torus face of a
/// tight sweep (tube touching its axis) at three times π²·R·r, its quarter-turn area; the
/// volumes agree. Their area is not compared.
const FUSION_AREA_OFF: &[&str] = &["50-sweep-tight-bend"];
/// Import a case's `part.step` (as File → Open does) and compare with Fusion's measurements:
/// body count, volume and area (relative 1e-3) and the faces read from the file.
/// Open the case's `part.step` (or `part.igs` with `iges`) and compare with measure.json.
fn check_step_case(dir: &str, iges: bool) -> Value {
let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let sp = format!("{dir}/{}", if iges { "part.igs" } else { "part.step" });
let want = match read_json(&format!("{dir}/measure.json")) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e}),
};
let t0 = std::time::Instant::now();
let mut s = solvecraft_engine::Session::default();
let opened = match s.execute("doc.open", &json!({"path": sp})) {
Ok(v) => v,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let got = match s.execute("inspect.measure", &json!({})) {
Ok(m) => m,
Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}),
};
let ms = t0.elapsed().as_secs_f64() * 1000.0;
// Faces read from the file (the kernel may split a face that wraps around a closed surface).
let file_faces = std::fs::read(&sp)
.ok()
.and_then(|b| {
let text = String::from_utf8_lossy(&b);
if iges {
solvecraft_engine::kernel::iges_import(&text).ok().map(std::sync::Arc::new)
} else {
solvecraft_engine::kernel::step_import_shared(&text).ok()
}
})
.map(|imp| imp.bodies.iter().map(|b| b.file_faces).sum::<usize>() as f64);
let mut checks = Vec::new();
let mut pass = true;
let mut num = |what: &str, g: Option<f64>, w: Option<f64>, rel: f64| {
let ok = match (g, w) {
(Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0),
_ => false,
};
pass &= ok;
checks.push(json!({"check": what, "got": g, "want": w, "ok": ok}));
};
num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0);
num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL);
if !FUSION_AREA_OFF.contains(&name.as_str()) {
num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL);
}
num("faces", file_faces, want["total"]["faces"].as_f64(), 0.0);
json!({
"case": name, "pass": pass, "checks": checks, "ms": ms,
"bodies": opened["bodies"], "warnings": opened["warnings"],
"info": {"kernel_faces_merged": got["total"]["faces"], "edges": got["total"]["edges"], "want_edges": want["total"]["edges"], "vertices": got["total"]["vertices"], "want_vertices": want["total"]["vertices"]},
})
}
pub fn step_corpus(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("step-corpus: give one or more case directories (each with part.step and measure.json)".into());
}
let iges = args.iter().any(|a| a == "--iges");
let results: Vec<Value> = dirs.iter().map(|d| check_step_case(d, iges)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} STEP case(s) failed")) } else { Ok(()) }
}
fn report(args: &[String], results: &[Value]) {
if args.iter().any(|a| a == "--json") {
println!("{}", serde_json::to_string_pretty(&Value::Array(results.to_vec())).unwrap_or_default());
return;
}
for r in results {
let status = if r["pass"].as_bool().unwrap_or(false) {
"PASS"
} else if r["skipped"].as_bool().unwrap_or(false) {
"SKIP"
} else {
"FAIL"
};
let detail: Vec<String> = r["checks"]
.as_array()
.into_iter()
.flatten()
.filter(|c| !c["ok"].as_bool().unwrap_or(false))
.map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"]))
.collect();
let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default();
println!(
"{status} {}{err}{}",
r["case"].as_str().unwrap_or(""),
if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) }
);
}
}
pub fn run(args: &[String]) -> Result<(), String> {
let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect();
if dirs.is_empty() {
return Err("oracle: give one or more case directories".into());
}
let results: Vec<Value> = dirs.iter().map(|d| check_case(d)).collect();
report(args, &results);
let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count();
if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) }
}
@@ -1,4 +1,4 @@
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft command scripts.
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
//!
//! A recipe lists features with sketch entities in sketch-local coordinates; point references
//! (`l1.end`) share points. Each feature becomes one or more commands.
@@ -85,6 +85,14 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
/// Extent parameters for the Extrude command.
fn extent(f: &Value, p: &mut Value) {
// Where Fusion's own result differs from the geometric meaning (its through-all is sized
// from vertices), the recipe records the extent Fusion actually used: replay that.
if let Some(fx) = f.get("fusion_effective_extent").filter(|x| x.get("type").and_then(Value::as_str) == Some("distance")) {
let dir = fx.get("direction").and_then(Value::as_str).unwrap_or("positive");
p["distance"] = fx.get("distance").cloned().unwrap_or(Value::Null);
p["direction"] = json!(if dir.starts_with("negative") { "negative" } else { "positive" });
return;
}
let e = f.get("extent").cloned().unwrap_or(Value::Null);
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
@@ -92,7 +100,7 @@ fn extent(f: &Value, p: &mut Value) {
"symmetric" => {
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
p["direction"] = json!("symmetric");
p["distance"] = if full { dist } else { json!(format!("({}) * 2", expr_text(&dist))) };
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
}
"two_sides" => {
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
@@ -296,17 +304,6 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
Some("sketch") => sketch_cmds(f, &mut out)?,
Some("extrude") => {
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
// Thin: {sketch, curves, thickness, side?, thickness2?} instead of profiles.
if let (Some(thin), Some(params)) = (f.get("thin"), p.as_object_mut()) {
params.remove("profiles");
params.insert("thin".into(), json!(true));
params.insert("sketch".into(), thin.get("sketch").cloned().unwrap_or(Value::Null));
for key in ["curves", "thickness", "side", "thickness2"] {
if let Some(v) = thin.get(key) {
params.insert(key.into(), v.clone());
}
}
}
extent(f, &mut p);
out.push(json!({"command": "solid.extrude", "params": p}));
}
@@ -525,23 +522,7 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
out.push(json!({"command": "solid.mirror", "params": p}));
}
Some("fillet") => {
let mut p = json!({"edges": edge_points(f), "radius": f.get("radius"), "name": name});
// Constant radius (the default), chord length, or a radius varying along each edge
// (`radius` at the end nearest `start`, `radius2` at the other, `mid` between,
// `smooth`), or a B-spline along each edge (`radius` its first control radius,
// `radii` the rest, `knots`); `fit` for a radius too large for the geometry.
match f.get("type").and_then(Value::as_str) {
Some("chord_length") => p["type"] = json!("chord"),
Some("variable_radius") => p["type"] = json!("variable"),
Some("spline_radius") => p["type"] = json!("spline"),
_ => {}
}
for key in ["radius2", "start", "mid", "smooth", "fit", "radii", "knots"] {
if let Some(v) = f.get(key) {
p[key] = v.clone();
}
}
out.push(json!({"command": "solid.fillet", "params": p}))
out.push(json!({"command": "solid.fillet", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
}
Some("chamfer") => {
out.push(json!({"command": "solid.chamfer", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}}))
@@ -564,8 +545,8 @@ fn edge_points(f: &Value) -> Value {
)
}
/// A pattern's source features may carry an occurrence suffix ("Hole1 (1)"); the features are
/// the recipe's own names.
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
/// features are the recipe's own names.
fn feature_refs(v: Option<&Value>) -> Value {
let list: Vec<Value> = v
.and_then(Value::as_array)
+321
View File
@@ -0,0 +1,321 @@
//! Seam-normalised topology of Fusion's measurements.
//!
//! Fusion splits a closed surface along seams: a full cylinder can be two half faces, and a
//! closed curve crossing a seam becomes two edges with a vertex on the seam. SolveCraft's merged
//! counts already count each closed surface patch once and ignore seam edges and vertices, so
//! the oracle normalises Fusion's side the same way from its face, edge and vertex lists:
//! - faces cut from one periodic surface (cylinder, cone, sphere, torus) and separated only by
//! seam edges count once;
//! - a seam edge lies on that surface and on no other face's surface: it is dropped;
//! - a vertex left with exactly two edges is no corner (a solid's corners have three or more):
//! the two edges join, and a curve that closes on itself keeps one vertex.
use std::collections::HashMap;
use serde_json::Value;
type V3 = [f64; 3];
const TOL: f64 = 1e-4;
fn v3(v: &Value) -> Option<V3> {
let a = v.as_array()?;
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?])
}
fn sub(a: V3, b: V3) -> V3 {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: V3, b: V3) -> V3 {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn mul(a: V3, s: f64) -> V3 {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: V3, b: V3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: V3, b: V3) -> V3 {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
fn len(a: V3) -> f64 {
dot(a, a).sqrt()
}
fn unit(a: V3) -> Option<V3> {
let l = len(a);
(l > 1e-12).then(|| mul(a, 1.0 / l))
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Surf {
Plane { p: V3, n: V3 },
Cylinder { o: V3, a: V3, r: f64 },
Cone { o: V3, a: V3, r: f64, tan: f64 },
Sphere { c: V3, r: f64 },
Torus { c: V3, a: V3, big: f64, small: f64 },
Other,
}
impl Surf {
fn of(f: &Value) -> Surf {
let g = |k: &str| v3(&f[k]);
let n = |k: &str| f[k].as_f64();
let s = match f["type"].as_str().unwrap_or_default() {
"plane" => g("centroid").zip(g("plane_normal_outward").and_then(unit)).map(|(p, n)| Surf::Plane { p, n }),
"cylinder" => (|| Some(Surf::Cylinder { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")? }))(),
"cone" => {
(|| Some(Surf::Cone { o: g("axis_origin")?, a: unit(g("axis")?)?, r: n("radius_mm")?, tan: n("half_angle_deg")?.to_radians().tan() }))(
)
}
"sphere" => (|| Some(Surf::Sphere { c: g("center")?, r: n("radius_mm")? }))(),
"torus" => (|| Some(Surf::Torus { c: g("center")?, a: unit(g("axis")?)?, big: n("major_radius_mm")?, small: n("minor_radius_mm")? }))(),
_ => None,
};
s.unwrap_or(Surf::Other)
}
fn periodic(&self) -> bool {
matches!(self, Surf::Cylinder { .. } | Surf::Cone { .. } | Surf::Sphere { .. } | Surf::Torus { .. })
}
/// Whether the point lies on the surface; `None` when that can't be told (free-form faces).
fn contains(&self, p: V3) -> Option<bool> {
let radial = |o: V3, a: V3| {
let d = sub(p, o);
let t = dot(d, a);
(t, len(sub(d, mul(a, t))))
};
Some(match *self {
Surf::Plane { p: q, n } => dot(sub(p, q), n).abs() < TOL,
Surf::Cylinder { o, a, r } => (radial(o, a).1 - r).abs() < TOL,
Surf::Cone { o, a, r, tan } => {
let (t, rho) = radial(o, a);
(rho - (r + t * tan)).abs() < TOL || (rho - (r - t * tan)).abs() < TOL
}
Surf::Sphere { c, r } => (len(sub(p, c)) - r).abs() < TOL,
Surf::Torus { c, a, big, small } => {
let (t, rho) = radial(c, a);
((rho - big).hypot(t) - small).abs() < TOL
}
Surf::Other => return None,
})
}
fn same(&self, o: &Surf) -> bool {
let close = |a: f64, b: f64| (a - b).abs() < TOL;
let pt = |a: V3, b: V3| len(sub(a, b)) < TOL;
let par = |a: V3, b: V3| len(cross(a, b)) < TOL;
// Same axis line: parallel, and either origin on the other's axis.
let axis = |o1: V3, a1: V3, o2: V3, a2: V3| par(a1, a2) && len(cross(sub(o2, o1), a1)) < TOL;
match (*self, *o) {
(Surf::Cylinder { o: o1, a: a1, r: r1 }, Surf::Cylinder { o: o2, a: a2, r: r2 }) => axis(o1, a1, o2, a2) && close(r1, r2),
(Surf::Cone { o: o1, a: a1, r: r1, tan: t1 }, Surf::Cone { .. }) => {
// Same cone: the other's frame circle lies on this one, same opening angle.
let Surf::Cone { o: o2, a: a2, r: r2, tan: t2 } = *o else { return false };
axis(o1, a1, o2, a2) && close(t1, t2) && {
let t = dot(sub(o2, o1), a1) * dot(a1, a2).signum();
close(r2, r1 + t * t1) || close(r2, r1 - t * t1)
}
}
(Surf::Sphere { c: c1, r: r1 }, Surf::Sphere { c: c2, r: r2 }) => pt(c1, c2) && close(r1, r2),
(Surf::Torus { c: c1, a: a1, big: b1, small: s1 }, Surf::Torus { c: c2, a: a2, big: b2, small: s2 }) => {
pt(c1, c2) && par(a1, a2) && close(b1, b2) && close(s1, s2)
}
_ => false,
}
}
}
/// Points along an edge from Fusion's edge list.
fn edge_points(e: &Value) -> Vec<V3> {
let (s, t) = (v3(&e["start"]), v3(&e["end"]));
let mut pts: Vec<V3> = s.into_iter().chain(t).collect();
let (c, r, a) = (v3(&e["center"]), e["radius_mm"].as_f64(), v3(&e["axis"]).and_then(unit));
match (e["type"].as_str().unwrap_or_default(), s, t) {
("line", Some(s), Some(t)) => pts.push(mul(add(s, t), 0.5)),
("arc", Some(s), Some(t)) => {
if let (Some(c), Some(r)) = (c, r)
&& let Some(m) = unit(add(sub(s, c), sub(t, c)))
{
let long = e["length_mm"].as_f64().is_some_and(|l| l > std::f64::consts::PI * r);
pts.push(add(c, mul(m, if long { -r } else { r })));
}
}
("circle", Some(s), _) => {
if let (Some(c), Some(a)) = (c, a) {
let d = sub(s, c);
for th in [2.0 * std::f64::consts::FRAC_PI_3, 4.0 * std::f64::consts::FRAC_PI_3] {
pts.push(add(c, add(mul(d, th.cos()), mul(cross(a, d), th.sin()))));
}
}
}
_ => {}
}
pts
}
fn key(p: V3) -> [i64; 3] {
p.map(|x| (x * 1e6).round() as i64)
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Faces, edges and vertices of one Fusion body after merging seam splits; `None` when the
/// lists are missing.
pub fn normalised(body: &Value) -> Option<(usize, usize, usize)> {
let faces = body["face_list"].as_array()?;
let edges = body["edge_list"].as_array()?;
let surfs: Vec<Surf> = faces.iter().map(Surf::of).collect();
// Group faces cut from one periodic surface.
let mut group: Vec<usize> = (0..surfs.len()).collect();
for i in 0..surfs.len() {
for j in 0..i {
if surfs[i].periodic() && surfs[i].same(&surfs[j]) {
group[i] = group[j];
break;
}
}
}
let size = |g: usize| group.iter().filter(|x| **x == g).count();
// Seam edges: on a split group's surface and on no other face's surface.
let mut seams_in: HashMap<usize, usize> = HashMap::new();
let mut seam = vec![false; edges.len()];
for (ei, e) in edges.iter().enumerate() {
let pts = edge_points(e);
if pts.is_empty() {
continue;
}
let on = |s: &Surf| pts.iter().map(|p| s.contains(*p)).try_fold(true, |acc, x| Some(acc && x?));
let carriers: Vec<Option<bool>> = surfs.iter().map(on).collect();
if carriers.contains(&None) {
continue;
}
let groups: Vec<usize> = (0..surfs.len()).filter(|f| carriers[*f] == Some(true)).map(|f| group[f]).collect();
if let Some(&g) = groups.first()
&& groups.iter().all(|x| *x == g)
&& surfs[g].periodic()
&& size(g) > 1
{
seam[ei] = true;
*seams_in.entry(g).or_insert(0) += 1;
}
}
let mut face_count = 0;
for g in 0..surfs.len() {
let k = size(g);
if k > 0 {
face_count += k.saturating_sub(seams_in.get(&g).copied().unwrap_or(0)).max(1);
}
}
// Vertex degrees over the remaining edges; a closed edge counts once at its vertex.
let mut ends: Vec<(usize, Option<[i64; 3]>, Option<[i64; 3]>)> = Vec::new();
for (ei, e) in edges.iter().enumerate() {
if seam[ei] {
continue;
}
let (s, t) = (v3(&e["start"]).map(key), v3(&e["end"]).map(key));
let t = if t == s { None } else { t };
ends.push((ends.len(), s, t));
}
// A seam between two closed curves (a closed face's seam whose surface we can't evaluate):
// an open edge whose ends each meet only a closed edge. At a real vertex three edges meet.
let closed_at = |v: &[i64; 3], ends: &[(usize, Option<[i64; 3]>, Option<[i64; 3]>)]| {
let closed = ends.iter().filter(|(_, s, t)| t.is_none() && s.as_ref() == Some(v)).count();
let open = ends.iter().filter(|(_, s, t)| t.is_some() && (s.as_ref() == Some(v) || t.as_ref() == Some(v))).count();
closed == 1 && open == 1
};
let loop_seams: Vec<usize> =
ends.iter().filter(|(_, s, t)| matches!((s, t), (Some(s), Some(t)) if closed_at(s, &ends) && closed_at(t, &ends))).map(|e| e.0).collect();
ends.retain(|e| !loop_seams.contains(&e.0));
for (k, e) in ends.iter_mut().enumerate() {
e.0 = k;
}
let mut deg: HashMap<[i64; 3], Vec<usize>> = HashMap::new();
for (i, s, t) in &ends {
for v in [s, t].into_iter().flatten() {
deg.entry(*v).or_default().push(*i);
}
}
let mut ep: Vec<usize> = (0..ends.len()).collect();
let mut merged: Vec<[i64; 3]> = Vec::new();
for (v, es) in &deg {
// Two open edges meeting: one curve split at a seam.
if let [i, j] = es[..]
&& i != j
&& ends.get(i).is_some_and(|e| e.2.is_some())
&& ends.get(j).is_some_and(|e| e.2.is_some())
{
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
if let Some(x) = ep.get_mut(ri) {
*x = rj;
}
merged.push(*v);
}
}
let mut real: HashMap<usize, bool> = HashMap::new();
for (i, s, t) in &ends {
let r = find(&mut ep, *i);
let has_real = [s, t].into_iter().flatten().any(|v| !merged.contains(v)) || (s.is_none() && t.is_none());
*real.entry(r).or_insert(false) |= has_real;
}
let closed = real.values().filter(|r| !**r).count();
let vertices = deg.keys().filter(|v| !merged.contains(v)).count() + closed;
Some((face_count, real.len(), vertices))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A cylinder (r 5, height 10) split by Fusion into two halves along two seams.
#[test]
fn split_cylinder_counts_once() {
let cyl = json!({"type": "cylinder", "axis_origin": [0, 0, 0], "axis": [0, 0, 1], "radius_mm": 5.0});
let plane = |z: f64, n: f64| json!({"type": "plane", "centroid": [0, 0, z], "plane_normal_outward": [0, 0, n]});
let arc = |z: f64, s: [f64; 3], t: [f64; 3]| json!({"type": "arc", "start": s, "end": t, "center": [0, 0, z], "radius_mm": 5.0, "axis": [0, 0, 1], "length_mm": 15.707963});
let line = |x: f64| json!({"type": "line", "start": [x, 0, 0], "end": [x, 0, 10]});
let body = json!({
"face_list": [cyl.clone(), cyl, plane(0.0, -1.0), plane(10.0, 1.0)],
"edge_list": [line(5.0), line(-5.0), arc(0.0, [5.0, 0.0, 0.0], [-5.0, 0.0, 0.0]), arc(0.0, [-5.0, 0.0, 0.0], [5.0, 0.0, 0.0]), arc(10.0, [5.0, 0.0, 10.0], [-5.0, 0.0, 10.0]), arc(10.0, [-5.0, 0.0, 10.0], [5.0, 0.0, 10.0])],
});
assert_eq!(normalised(&body), Some((3, 2, 2)));
}
/// Oracle 35: a fillet (free-form face, surface unknown) between two cylinders is bounded by
/// two closed contact curves joined by its seam arc. The arc goes; the curves and circles stay.
#[test]
fn seam_between_closed_curves_on_an_unknown_surface() {
let circle =
|s: [f64; 3], c: [f64; 3], a: [f64; 3]| json!({"type": "circle", "start": s, "end": s, "center": c, "radius_mm": 1.0, "axis": a});
let lp = |p: [f64; 3]| json!({"type": "nurbs", "start": p, "end": p});
let (p, q) = ([0.0, -10.833333333, 10.374916332], [0.0, -10.0, 12.449899598]);
let body = json!({
"face_list": [{"type": "nurbs"}],
"edge_list": [
{"type": "arc", "start": p, "end": q, "center": [0.0, -13.0, 12.449899598], "radius_mm": 3.0, "axis": [1, 0, 0]},
circle([-40.0, 0.0, 15.0], [-40.0, 0.0, 0.0], [-1.0, 0.0, 0.0]),
circle([-10.0, 0.0, 35.0], [0.0, 0.0, 35.0], [0.0, 0.0, 1.0]),
circle([40.0, 0.0, 15.0], [40.0, 0.0, 0.0], [1.0, 0.0, 0.0]),
lp(p),
lp(q),
],
});
assert_eq!(normalised(&body), Some((1, 5, 5)));
}
/// A closed curve split into two edges at a seam vertex joins again (Fusion part 61's keyway).
#[test]
fn degree_two_vertices_join() {
let n = |s: [f64; 3], t: [f64; 3]| json!({"type": "nurbs", "start": s, "end": t});
let body = json!({"face_list": [], "edge_list": [n([0.0, 0.0, 0.0], [1.0, 0.0, 0.0]), n([1.0, 0.0, 0.0], [0.0, 0.0, 0.0])]});
assert_eq!(normalised(&body), Some((0, 1, 1)));
}
}
@@ -0,0 +1,20 @@
# Design file corpus
Designs saved by older SolveCraft builds. `tests/design_corpus.rs` opens each one with the
current build and checks that it evaluates as it did when saved (the `.expected.json` next to
it is `solvecraft-cli eval` of that file by the build that saved it: bodies, volumes, areas,
world bounding boxes), that it is upgraded to the current file format, and that damaged and
hostile variants of it never panic.
| File | Saved by | Era |
|---|---|---|
| `m0-box-fillet-hole` | 3714342 | M0: sketch, parameters, extrude, fillet, cut |
| `m0-revolve-primitives` | 3714342 | M0: revolve, primitives, combine, chamfer, timeline edit |
| `components-arm` | 97d03e3 | components and occurrences: move, copy, Paste New, ground |
| `joints-hinge` | c2309eb | joints: revolute hinge driven by a parameter |
| `modern-sheet-metal` | bd98035 | sheet metal contour flange (oracle 72) |
| `modern-path-pattern` | bd98035 | pattern on path (oracle 53) |
| `modern-plastic` | bd98035 | shell, lip, boss, rest, snap fit, plastic rule |
The scripts that made them are in `scripts/`. Never regenerate a file with a newer build: the
point is that it was written by the old one. Add new files for new eras instead.
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [
@@ -1,5 +1,5 @@
{
"format": "solidcraft/1",
"format": "solvecraft/1",
"name": "Untitled",
"units": "mm",
"params": [],
Loaded 100 of 1244 files, more files were not shown because too many files have changed in this diff. Show more