Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9879fa668 | ||
|
|
45bc22fc30 | ||
|
|
4aaa5457b3 | ||
|
|
1fd4a2f782 | ||
|
|
00baf1d883 | ||
|
|
91de39d3fe | ||
|
|
219816d282 | ||
|
|
2880525e6b | ||
|
|
aa156f4a9f | ||
|
|
ccc36dc1f9 | ||
|
|
0fe032438f | ||
|
|
72c5d974b8 | ||
|
|
201e9d5f9f | ||
|
|
af0440aaab | ||
|
|
49956c2797 | ||
|
|
b24e478bc1 | ||
|
|
9ab25cfd4a | ||
|
|
1ae6e9beb4 | ||
|
|
fc1d70cc3b | ||
|
|
dd67472252 | ||
|
|
9062c739d8 | ||
|
|
fb4c1d74b9 | ||
|
|
0dee9deec1 | ||
|
|
722bd007c8 | ||
|
|
9e60080b10 | ||
|
|
455ca86a6c | ||
|
|
6fde5ff55b | ||
|
|
7f92093cc8 | ||
|
|
ddd528abaf | ||
|
|
5c7149e57d | ||
|
|
809ecf781e | ||
|
|
01bf0e4eaf | ||
|
|
0d995c6a78 | ||
|
|
006a23a767 | ||
|
|
8e0c3d2794 | ||
|
|
f34ac5881f | ||
|
|
f290382498 | ||
|
|
deb96318a1 | ||
|
|
11ecb64059 | ||
|
|
1d7450b04c | ||
|
|
968a22897c | ||
|
|
0154572580 | ||
|
|
0bb5b7eb09 | ||
|
|
b447adafaa | ||
|
|
3938e267bb | ||
|
|
daed2686bc | ||
|
|
cddbfedb28 | ||
|
|
e56e055fa1 | ||
|
|
296683229d | ||
|
|
27415e4dd7 | ||
|
|
55f2f29fff | ||
|
|
d381d2b7f3 | ||
|
|
c62879b00d | ||
|
|
3c94f59d43 | ||
|
|
829912c6c5 | ||
|
|
6090e89b3f | ||
|
|
f06b76e14f | ||
|
|
4b8872e4d0 | ||
|
|
e7b5b2618e | ||
|
|
ef9bb0db09 | ||
|
|
f538eeb5fa | ||
|
|
d470bfc52a | ||
|
|
f82b6beedb | ||
|
|
80672460ed | ||
|
|
57e6396fbb | ||
|
|
a5cc1243e3 | ||
|
|
afcec4d0bd | ||
|
|
6efbd6ff14 | ||
|
|
c51735c885 | ||
|
|
4eaadf3099 | ||
|
|
882ef0e9c6 | ||
|
|
d15869ab85 | ||
|
|
511d105dcb | ||
|
|
2ebceb6918 | ||
|
|
7db66bb824 | ||
|
|
a7e47fdae6 | ||
|
|
ce226a88eb | ||
|
|
0b122e6d4a | ||
|
|
b2fc6d1fac | ||
|
|
e6a95615a0 | ||
|
|
6dab0fb2c1 | ||
|
|
a04ef4ca59 | ||
|
|
8e87a37511 | ||
|
|
fe4d171e22 | ||
|
|
75a5609b23 | ||
|
|
fc8a5b21cf | ||
|
|
e6252ae094 | ||
|
|
198f45edee | ||
|
|
354a267830 | ||
|
|
c33fe37798 | ||
|
|
667e05a0c9 | ||
|
|
a9efae864d | ||
|
|
cc668668ce | ||
|
|
cfdd8a533b | ||
|
|
08cf526fe2 | ||
|
|
2bda050872 | ||
|
|
08d797235d | ||
|
|
97bcdb4d06 | ||
|
|
688f806682 | ||
|
|
43ddde4a5d | ||
|
|
08d7037176 | ||
|
|
d5cb276965 | ||
|
|
d69ebbee1b | ||
|
|
8c81bc60d0 | ||
|
|
cc4955a8da | ||
|
|
c87efa1f35 | ||
|
|
65a99608d9 | ||
|
|
a9e4830ded | ||
|
|
a3ff816176 | ||
|
|
06e517eb4e | ||
|
|
4d4b3a715f | ||
|
|
6bf86f1f1e | ||
|
|
516d80ae8d | ||
|
|
250d8a2c04 | ||
|
|
e6e0a7ebb5 | ||
|
|
cb8aa38cc9 | ||
|
|
14163d0965 | ||
|
|
3b8028a040 | ||
|
|
c7f0139ad4 | ||
|
|
f525704192 | ||
|
|
db6ecaf446 | ||
|
|
59fabc16ac | ||
|
|
aea16f2b5a | ||
|
|
93fd18eaad | ||
|
|
b45cae1e48 | ||
|
|
e12758ff91 | ||
|
|
f0dda667bb | ||
|
|
611422e4df | ||
|
|
2cac9cad6a | ||
|
|
3d99ab4bb1 | ||
|
|
e3938defd8 | ||
|
|
f3751bd3ca | ||
|
|
7d7ef18f49 | ||
|
|
f77da83c93 | ||
|
|
62847173f4 | ||
|
|
df09fdbc32 | ||
|
|
87bb2f37f8 | ||
|
|
879aacacf8 | ||
|
|
a9007d7b86 | ||
|
|
56a2ed1e4f | ||
|
|
bf60758244 | ||
|
|
d9613e29cb | ||
|
|
0a526c6f85 | ||
|
|
4fbf19f2f9 | ||
|
|
1fee62dd54 | ||
|
|
ab72c5ad56 | ||
|
|
df4c8a2994 | ||
|
|
417511367c | ||
|
|
c3f94ab616 | ||
|
|
8eda982489 | ||
|
|
cab698bf12 | ||
|
|
c308a1ba7a | ||
|
|
874315db8e | ||
|
|
56b499e951 | ||
|
|
8c13914d6c | ||
|
|
f0292b1606 | ||
|
|
732447d2dc | ||
|
|
db0a2a007a | ||
|
|
ed102b5393 | ||
|
|
970da30a99 | ||
|
|
113ded5fab | ||
|
|
b4491ac605 | ||
|
|
53b092d287 | ||
|
|
25ea43e89f |
No files matched your search
@@ -0,0 +1,29 @@
|
||||
# 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(=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"
|
||||
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [bherbruck]
|
||||
ko_fi: bherbruck
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Help reproduce and fix a SolveCraft problem
|
||||
about: Help reproduce and fix a SolidCraft problem
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
@@ -12,7 +12,7 @@ What did you expect, and what happened instead?
|
||||
|
||||
## Reproduction
|
||||
|
||||
Include steps, SolveCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
Include steps, SolidCraft version/commit and relevant environment details. Prefer a small example without private design data.
|
||||
|
||||
## Screenshots of the behavior (when observable)
|
||||
|
||||
|
||||
@@ -3,41 +3,86 @@ parameter to watch the timeline rebuild. [Documentation](https://bherbruck.githu
|
||||
· [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:
|
||||
`solvecraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
`solidcraft-cli mcp` ([how](https://bherbruck.github.io/solidcraft/guide/mcp.html)).
|
||||
|
||||
Compatibility names are unchanged: existing `.solvecraft` designs, `solvecraft-cli`, package
|
||||
IDs and archive filenames still work. Published 0.2.0 and 0.2.1 releases keep their original
|
||||
SolveCraft branding. Documentation, the browser app and the Flatpak feed use the new
|
||||
SolidCraft project Pages URLs; old-name Pages addresses are not permanent aliases.
|
||||
**Why 0.7:** this is a minor bump because the design file format moved to version 15 (sketches
|
||||
on cylinder caps and sheet metal panels need new references), and because it adds new features.
|
||||
|
||||
**What's new**
|
||||
- **Sketching on stacked extrudes works again.** Creating a sketch on the top face of an extrude
|
||||
stacked on another failed with "operation failed" since 0.3
|
||||
([#489](https://github.com/bherbruck/solidcraft/issues/489)). It's fixed, and every flat face
|
||||
of the test models is now checked for this on every change.
|
||||
- **Select Other:** hold or right-click to pick something hidden under something else; click
|
||||
again to cycle through what's underneath.
|
||||
- **Tabs are named after their file** instead of "Untitled".
|
||||
- **Modelling:** a flange angle and height per edge, Patch fills non-planar loops, sketches on
|
||||
cylinder caps and on sheet metal faces, and a feature rename updates everything that refers to
|
||||
it.
|
||||
- **Fixes:** revolve with edges on the axis, shell and fillet with small rounds, Join keeps face
|
||||
attachments, and STEP export writes each lump of a body as its own solid.
|
||||
- **Navigation and UI:** orbiting from empty space turns around the visible geometry, Ctrl+drag
|
||||
orbits in every preset, toolbar drops land across labels and gaps, Combine previews before OK,
|
||||
and sketch constraints accept the sketch's own x / y axes.
|
||||
- Opening a design reads its metadata and preview through a bounded, seekable reader; the docs
|
||||
and web app have link preview cards.
|
||||
|
||||
**File format.** Designs are now format 15 (`solidcraft/15`). SolidCraft 0.6 and older can't
|
||||
open designs saved by 0.7, as a container or as JSON; keep a copy of a file you may still open
|
||||
in 0.6. Files from every earlier version still open in 0.7.
|
||||
|
||||
**Known limitations**
|
||||
- Some faces still need the fixed-plane fallback for sketches: side faces Join merged from two
|
||||
extrudes ([#491](https://github.com/bherbruck/solidcraft/issues/491)), faces split by a later
|
||||
feature ([#492](https://github.com/bherbruck/solidcraft/issues/492)), Cut walls and chamfer
|
||||
faces ([#493](https://github.com/bherbruck/solidcraft/issues/493)), faces after Revolve,
|
||||
Offset Face or Boss ([#494](https://github.com/bherbruck/solidcraft/issues/494)) and sheet
|
||||
metal thickness faces ([#495](https://github.com/bherbruck/solidcraft/issues/495)).
|
||||
- Cutting a coaxial cylinder from a thin disc can lose a little volume
|
||||
([#500](https://github.com/bherbruck/solidcraft/issues/500)); an edge flange with different
|
||||
per-edge angles leaves gaps at the corners
|
||||
([#501](https://github.com/bherbruck/solidcraft/issues/501)).
|
||||
- More in [ROADMAP.md](https://github.com/bherbruck/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
|
||||
**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.
|
||||
|
||||
**Which file?**
|
||||
- **Windows:** `SolveCraft-{version}-windows-x64.exe` runs as is. Or install the `.msi`, or unzip the
|
||||
- **Windows:** `SolidCraft-{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 `SolveCraft.app` onto Applications.
|
||||
The CLI is in `solvecraft-cli-{version}-macos-universal.zip`.
|
||||
- **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`.
|
||||
- **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>`), 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.
|
||||
`.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.
|
||||
- `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 solvecraft https://bherbruck.github.io/solidcraft/flatpak/solvecraft.flatpakrepo
|
||||
flatpak install --user solvecraft ai.storyteller.solvecraft
|
||||
flatpak remote-add --user --if-not-exists solidcraft https://bherbruck.github.io/solidcraft/flatpak/solidcraft.flatpakrepo
|
||||
flatpak install --user solidcraft ai.storyteller.solvecraft
|
||||
flatpak run ai.storyteller.solvecraft
|
||||
```
|
||||
|
||||
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 `SolveCraft.app` in Applications and choose
|
||||
**Open** the first time (or run `xattr -dr com.apple.quarantine /Applications/SolveCraft.app`).
|
||||
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`).
|
||||
On Windows, SmartScreen may say "Windows protected your PC": **More info**, then **Run anyway**.
|
||||
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/solidcraft/blob/main/ROADMAP.md#known-limitations).
|
||||
Licences are built in (Help ▸ About ▸ Licences, `solidcraft-cli licences`); the packages also
|
||||
install `THIRD-PARTY-LICENSES.txt`.
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# 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:
|
||||
@@ -16,7 +22,8 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# Every push to main gets its full run; a new PR push replaces the old run.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -39,7 +46,7 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
native=false
|
||||
if [ "$EVENT_NAME" = workflow_dispatch ]; then
|
||||
if [ "$EVENT_NAME" != pull_request ]; then
|
||||
native=true
|
||||
else
|
||||
# Compare both old and new paths of renames; unknown paths run native checks.
|
||||
@@ -92,12 +99,12 @@ jobs:
|
||||
key: native-clippy
|
||||
- run: cargo clippy --workspace --all-targets --profile ci --locked -- -D warnings
|
||||
|
||||
tests:
|
||||
name: Native Linux tests
|
||||
build-tests:
|
||||
name: Build tests
|
||||
needs: changes
|
||||
if: needs.changes.outputs.native == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTC_WRAPPER: sccache
|
||||
@@ -113,18 +120,117 @@ jobs:
|
||||
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
with:
|
||||
version: v0.16.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
|
||||
- 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: Compiler cache statistics
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
tests:
|
||||
name: Native Linux tests (${{ matrix.shard }}/4)
|
||||
needs: 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: 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
|
||||
|
||||
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
|
||||
|
||||
# Preserve the existing check name for branch protection, including docs-only PRs.
|
||||
ci:
|
||||
needs: [changes, checks, clippy, tests]
|
||||
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle]
|
||||
if: always()
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
@@ -134,13 +240,19 @@ 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 }}
|
||||
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
|
||||
fi
|
||||
|
||||
wasm:
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 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
|
||||
@@ -52,8 +52,10 @@ jobs:
|
||||
}
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/solvecraft.wxs packaging/windows/solvecraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout packaging/windows/solidcraft.wxs packaging/windows/solidcraft.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'"
|
||||
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'] == '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
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
- name: Build the book
|
||||
run: cargo xtask book
|
||||
- name: Build the web app
|
||||
working-directory: apps/solvecraft-web
|
||||
working-directory: apps/solidcraft-web
|
||||
run: trunk build --release --public-url /solidcraft/app/
|
||||
- name: Assemble the site
|
||||
run: |
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
# 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, .tar.gz packaging/linux
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .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
|
||||
|
||||
@@ -79,7 +83,7 @@ jobs:
|
||||
{ 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"
|
||||
|
||||
# The app icon in every size, drawn by solvecraft-cli (no image files are committed), for the
|
||||
# The app icon in every size, drawn by solidcraft-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
|
||||
@@ -91,10 +95,10 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solvecraft-cli
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solvecraft-cli icons
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
@@ -114,8 +118,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -135,17 +139,22 @@ jobs:
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/solvecraft$|usr/bin/solvecraft-cli$|metainfo|applications|THIRD-PARTY-LICENSES.txt'
|
||||
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
|
||||
rpm -qip dist/release/*.rpm
|
||||
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
|
||||
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
|
||||
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 }}
|
||||
@@ -166,8 +175,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
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
|
||||
@@ -181,7 +190,7 @@ jobs:
|
||||
# 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/solvecraft-$SOLVECRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/solidcraft-$SOLIDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
@@ -190,6 +199,59 @@ 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
|
||||
@@ -198,8 +260,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
@@ -211,7 +273,7 @@ jobs:
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLVECRAFT_VERSION SOLVECRAFT_BUILD_DATE"
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_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
|
||||
@@ -221,10 +283,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/solvecraft-target CARGO_BUILD_JOBS=4
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
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$'
|
||||
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$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
@@ -237,8 +299,8 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLVECRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
@@ -269,7 +331,7 @@ jobs:
|
||||
# Windows on ARM: cross-compiled on the x64 runner.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
@@ -290,7 +352,7 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLVECRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
@@ -304,7 +366,7 @@ jobs:
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SOLVECRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -328,7 +390,7 @@ jobs:
|
||||
|
||||
release:
|
||||
name: GitHub Release or dry-run artifacts
|
||||
needs: [version, linux, flatpak, freebsd, macos, windows, web]
|
||||
needs: [version, linux, flatpak, snap, freebsd, macos, windows, web]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -345,6 +407,24 @@ 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)
|
||||
@@ -398,6 +478,35 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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
|
||||
@@ -2,24 +2,21 @@
|
||||
|
||||
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. 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.
|
||||
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 and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
## 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.
|
||||
|
||||
## 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. Behaviour reference: `plan/fusion/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
and the relevant `plan/architecture.md` section.
|
||||
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
|
||||
@@ -36,26 +33,35 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **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
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
`solidcraft_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`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- **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.
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **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
|
||||
- **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
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft 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.
|
||||
- **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.
|
||||
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
|
||||
@@ -72,10 +78,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **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. 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.
|
||||
- **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.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
@@ -112,21 +120,22 @@ 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 solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --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`):
|
||||
- `{"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 `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`.
|
||||
`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`.
|
||||
|
||||
## Roadmap
|
||||
`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`.
|
||||
`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.
|
||||
@@ -4,8 +4,8 @@ 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):** SolveCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolveCraft contributors or
|
||||
**Policy (mandatory):** SolidCraft contains **no Autodesk iconography, images, artwork, fonts,
|
||||
templates, materials or help text.** Every asset is original work by SolidCraft contributors or
|
||||
third-party material under an open licence. Screenshots of Autodesk software are never committed.
|
||||
|
||||
Generated-in-code assets are original and have no file to list:
|
||||
@@ -17,37 +17,78 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | 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 |
|
||||
| assets/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/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 |
|
||||
| assets/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/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/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/Assets.car | SolidCraft contributors | Apple actool compilation of assets/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 |
|
||||
| assets/icon.vectorcraft | SolidCraft contributors | editable VectorCraft source for assets/icon.svg, with named face and background layers | MIT OR Apache-2.0 |
|
||||
| assets/icon.png | SolidCraft contributors | 1024 × 1024 transparent sRGB export of assets/icon.svg from VectorCraft | MIT OR Apache-2.0 |
|
||||
| assets/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/SolidCraft.icon composition | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_16x16.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_16x16@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_32x32.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_32x32@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_128x128.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_128x128@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_256x256.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_256x256@2x.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_512x512.png | SolidCraft contributors | native sips export from assets/icon.png | MIT OR Apache-2.0 |
|
||||
| assets/icon.iconset/icon_512x512@2x.png | SolidCraft contributors | native sips export from assets/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.6.gif | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/screenshots/demo-0.6.mp4 | SolidCraft contributors | original screen recording of the actual SolidCraft 0.6 English desktop UI modelling an original Raspberry Pi 4 case (no logos or branding), generated with examples/demo/record_ui.sh and its original animated pointer overlay (6× playback) | MIT OR Apache-2.0 |
|
||||
| docs/book/src/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 |
|
||||
| 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 |
|
||||
| apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf | The BIZ UDGothic Project Authors | https://github.com/googlefonts/morisawa-biz-ud-gothic (Google Fonts), unmodified; licence text in apps/solidcraft-web/fonts/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 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
| SolveCraft file | Adapted from | Licence |
|
||||
| SolidCraft file | Adapted from | Licence |
|
||||
|---|---|---|
|
||||
| xtask/src/layers.rs | CADCraft `xtask/src/layers.rs` (dependency layering rules and tests) | 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 |
|
||||
| apps/solidcraft/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/solvecraft/src/main.rs | VectorCraft `crates/ui-egui/src/titlebar.rs`, `apps/vectorcraft/src/main.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 |
|
||||
| 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 |
|
||||
|
||||
## Recovered contributions
|
||||
|
||||
| SolveCraft file | Original contribution | Licence |
|
||||
| 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/solvecraft/pull/101): [minimal TOP/BOTTOM turns](https://github.com/bherbruck/solvecraft/commit/c28e780f52490784ef5886f094b9d3f6d10c3e3f) and [drag-to-orbit](https://github.com/bherbruck/solvecraft/commit/f30b30be6ecc4e2e6544a851243137c01a354934), adapted to the axis gizmo | MIT OR Apache-2.0 |
|
||||
| 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
|
||||
|
||||
@@ -55,7 +96,7 @@ Generated-in-code assets are original and have no file to list:
|
||||
|---|---|---|---|
|
||||
| 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; trace macro (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 (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
|
||||
|
||||
@@ -2,24 +2,21 @@
|
||||
|
||||
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. 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.
|
||||
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 and compatibility
|
||||
SolidCraft is the displayed product name; `solidcraft` is the repository name. Keep existing
|
||||
`solvecraft` crate/binary names, `.solvecraft` files, format markers, environment variables,
|
||||
settings/recovery paths and package IDs compatible. Published SolveCraft release artifacts
|
||||
and attribution records keep their historical names. Public docs/web/Flatpak deployments
|
||||
use project Pages `/solidcraft/`; do not add permanent old-name Pages aliases. See README's
|
||||
compatibility notes.
|
||||
## 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.
|
||||
|
||||
## 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. Behaviour reference: `plan/fusion/`
|
||||
(`menu-tree.json` is Fusion's toolbar dump; `oracle/*/` are parts built in Fusion with their
|
||||
recipes and measurements).
|
||||
and the relevant `plan/architecture.md` section.
|
||||
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
|
||||
@@ -36,26 +33,35 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **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
|
||||
`solvecraft_kernel::guard`, which turns a panic into `KernelError::Internal`.
|
||||
`solidcraft_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`.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`).
|
||||
- **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.
|
||||
- Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **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
|
||||
- **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
|
||||
operations are our own.
|
||||
- **Assets — absolutely essential.** SolidCraft 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.
|
||||
- **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.
|
||||
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
|
||||
@@ -72,10 +78,12 @@ People trust SolidCraft with their designs; a crash loses their work. **This out
|
||||
- **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. 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.
|
||||
- **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.
|
||||
- **Never break wasm**: everything below the apps must build for `wasm32-unknown-unknown`
|
||||
(`cargo xtask wasm`, part of `cargo xtask ci`).
|
||||
|
||||
@@ -89,21 +97,22 @@ 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 solvecraft -- --sample --control PORT` (sample design + control channel).
|
||||
- `cargo run --release -p solidcraft -- --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`):
|
||||
- `{"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 `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`.
|
||||
`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`.
|
||||
|
||||
## Roadmap
|
||||
`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`.
|
||||
`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.
|
||||
@@ -17,23 +17,23 @@ build you also need `rustup target add wasm32-unknown-unknown` and [`trunk`](htt
|
||||
|
||||
```sh
|
||||
git clone https://github.com/bherbruck/solidcraft && cd solidcraft
|
||||
cargo run --release -p solvecraft -- --sample # desktop app with a sample part
|
||||
cargo run --release -p solidcraft -- --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 and repository name. Cargo packages and executables retain
|
||||
their `solvecraft` names for compatibility with existing scripts and MCP clients.
|
||||
Build the desktop app with `cargo build --release -p solvecraft`, or the CLI with
|
||||
`cargo build --release -p solvecraft-cli`. Run the CLI with
|
||||
`cargo run --release -p solvecraft-cli -- commands` (or `target/release/solvecraft-cli commands`
|
||||
after building). There is no Cargo package named `solidcraft`.
|
||||
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/solvecraft-web && trunk serve --release`, then open the printed URL (`?sample`
|
||||
Web: `cd apps/solidcraft-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 solvecraft`.
|
||||
Windows from Linux: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
### NixOS: `nix develop`
|
||||
|
||||
@@ -43,7 +43,7 @@ loader wgpu renders through, and libdbus-1, which rfd's file dialogs reach xdg-d
|
||||
through (a dialog then never appears and the pick returns no path):
|
||||
|
||||
```
|
||||
ERROR solvecraft: winit EventLoopError: ... The wayland library could not be loaded
|
||||
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
|
||||
```
|
||||
@@ -59,8 +59,8 @@ The default development shell retains the Rust/C toolchain already on `PATH`;
|
||||
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 and compatible `solvecraft`
|
||||
command run the same wrapped app; `solvecraft-cli` is included as well. See
|
||||
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`.
|
||||
|
||||
@@ -82,19 +82,22 @@ 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/solvecraft`, `apps/solvecraft-cli`, `apps/solvecraft-web`.
|
||||
- `crates/mcp`, `apps/solidcraft`, `apps/solidcraft-cli`, `apps/solidcraft-web`.
|
||||
|
||||
`cargo xtask layers` fails the build if a lower layer depends on a higher one.
|
||||
|
||||
## Rules that aren't negotiable
|
||||
|
||||
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).
|
||||
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`).
|
||||
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. Permissive
|
||||
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
|
||||
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).
|
||||
3. **Every asset is original or openly licensed and has a row in
|
||||
@@ -104,7 +107,7 @@ The [README](README.md#architecture) has the layer table. The short version:
|
||||
- Non-test code never uses `unwrap`, `expect`, `panic!` or indexing on input-derived data.
|
||||
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 `solvecraft_kernel::guard`.
|
||||
- Every call into truck goes through `solidcraft_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,
|
||||
@@ -119,7 +122,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` |
|
||||
| Fusion oracle | `cargo xtask oracle` | needs the local oracle data (see below) |
|
||||
| Reference tests | `cargo xtask oracle` | `tests/oracle/` (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.
|
||||
@@ -138,6 +141,10 @@ 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
|
||||
@@ -146,11 +153,28 @@ 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.
|
||||
|
||||
**The Fusion oracle** is a set of 76 reference parts, each built in Fusion from a recipe.
|
||||
SolidCraft 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.
|
||||
**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`,
|
||||
`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 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.
|
||||
|
||||
## Sending a change
|
||||
|
||||
|
||||
@@ -3289,88 +3289,93 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"embed-resource",
|
||||
"hidapi",
|
||||
"log",
|
||||
"quick-xml 0.38.4",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-cli"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-cli"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-mcp",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-mcp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-doc"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-doc"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-kernel",
|
||||
"solvecraft-sketch",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-engine"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-engine"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-doc",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-io",
|
||||
"solvecraft-kernel",
|
||||
"solvecraft-render",
|
||||
"solvecraft-sketch",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
"solidcraft-kernel",
|
||||
"solidcraft-render",
|
||||
"solidcraft-sketch",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-geom"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-geom"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-io"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-io"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.38.4",
|
||||
"solvecraft-doc",
|
||||
"solvecraft-geom",
|
||||
"solvecraft-kernel",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-kernel",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-kernel"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"solvecraft-geom",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
@@ -3380,37 +3385,37 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-mcp"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solidcraft-engine",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-render"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-render"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
"solidcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-sketch"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-geom",
|
||||
"solidcraft-geom",
|
||||
"thiserror 2.0.21",
|
||||
"ttf-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-ui-egui"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3418,22 +3423,22 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solidcraft-engine",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solvecraft-web"
|
||||
version = "0.3.2"
|
||||
name = "solidcraft-web"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"solvecraft-ui-egui",
|
||||
"solidcraft-engine",
|
||||
"solidcraft-ui-egui",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -4848,7 +4853,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.3.2"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.2"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -14,15 +14,15 @@ repository = "https://github.com/bherbruck/solidcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
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" }
|
||||
solidcraft-geom = { path = "crates/geom" }
|
||||
solidcraft-sketch = { path = "crates/sketch" }
|
||||
solidcraft-kernel = { path = "crates/kernel" }
|
||||
solidcraft-render = { path = "crates/render" }
|
||||
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" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -92,9 +92,11 @@ opt-level = 2
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
# 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"
|
||||
|
||||
# truck-shapeops with SolveCraft fixes (Apache-2.0, see vendor/truck-shapeops and ATTRIBUTION.md).
|
||||
# truck-shapeops with SolidCraft 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" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 SolveCraft contributors
|
||||
Copyright (c) 2026 SolidCraft 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
SolveCraft
|
||||
Copyright (c) 2026 SolveCraft contributors.
|
||||
SolidCraft
|
||||
Copyright (c) 2026 SolidCraft 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,
|
||||
`solvecraft-cli licences`). The geometry kernel builds on the truck crates
|
||||
`solidcraft-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 SolveCraft
|
||||
as libraries. truck-shapeops 0.4.0 is vendored in vendor/truck-shapeops with SolidCraft
|
||||
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,9 +19,10 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
SolveCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
SolidCraft 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.
|
||||
@@ -4,37 +4,70 @@
|
||||
|
||||
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. Part of the storytold "craft" family.
|
||||
model through the built-in MCP server.
|
||||
|
||||

|
||||

|
||||
|
||||
*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).*
|
||||
*A Raspberry Pi 4 case from an empty design: a sketch dimensioned with the board's parameters, extrude, fillet, shell, an M2.5 standoff patterned on the 58 × 49 mm hole rectangle, port cut-outs, vent slots and a colour, then height 24 → 32 mm in Parameters and it all rebuilds. 6× playback. [Full video](https://github.com/bherbruck/solidcraft/raw/refs/heads/main/docs/screenshots/demo-0.6.mp4), recorded with the [demo script](examples/demo/record_ui.sh).*
|
||||
|
||||
## Download
|
||||
|
||||
[![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.
|
||||
- **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)), and the
|
||||
[web app](https://bherbruck.github.io/solidcraft/app/) to try it in the browser.
|
||||
developer guide and command reference; sources in [docs/book](docs/book)).
|
||||
|
||||
Linux Flatpak installation needs the Freedesktop runtime from Flathub as well as the
|
||||
SolidCraft application feed. See [install and launch commands](https://bherbruck.github.io/solidcraft/guide/getting-started.html#linux-flatpak).
|
||||
|
||||
**Status: early but usable for simple parts.** The historical benchmark report records
|
||||
76 of 76 reference parts matching volume, area and topology ([docs/oracle.md](docs/oracle.md)).
|
||||
These checks do not establish complete geometric or workflow equivalence. Feature coverage
|
||||
is tracked in [docs/parity.md](docs/parity.md) and [ROADMAP.md](ROADMAP.md).
|
||||
**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),
|
||||
and help is welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Name and compatibility
|
||||
|
||||
SolidCraft was previously called SolveCraft. The product display name and repository are
|
||||
changing; existing identifiers remain compatible: `.solvecraft` designs, `solvecraft-cli`,
|
||||
Cargo package names, settings/recovery locations and package IDs keep their existing names.
|
||||
Published 0.2.0 and 0.2.1 packages and demo recordings retain their original SolveCraft branding
|
||||
and filenames.
|
||||
|
||||
Use the SolidCraft documentation and web-app URLs above. GitHub redirects links to the
|
||||
renamed repository; old-name Pages and Flatpak URLs are not maintained as permanent aliases.
|
||||
|
||||
## What works
|
||||
|
||||
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
|
||||
@@ -48,11 +81,15 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
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 (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.
|
||||
- **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. Fit keeps sketch geometry clear of the palette and navigation controls.
|
||||
- **Profiles** found automatically, including regions split by lines and holes inside outlines.
|
||||
@@ -73,11 +110,14 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
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**: designs as JSON (`.solvecraft`), export to STEP (AP242 with body names, colours and
|
||||
- **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
|
||||
experimental preview (explicit `preview: true` engine saves) while checking geometry before editing. 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;
|
||||
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
|
||||
need B-rep bodies);
|
||||
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
|
||||
as parametric solids with planar and circular cylindrical faces;
|
||||
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
|
||||
@@ -87,31 +127,45 @@ renamed repository; old-name Pages and Flatpak URLs are not maintained as perman
|
||||
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** `solvecraft-cli`: run command scripts, measure (volume, area, centre of mass,
|
||||
- **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** `solvecraft-cli mcp`: AI agents model, measure and look at parts headless or
|
||||
- **MCP server** `solidcraft-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 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 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 -- 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 xtask ci # fmt, clippy, tests, assets, layers, wasm
|
||||
```
|
||||
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solvecraft`.
|
||||
Windows builds: `cargo xwin build --release --target x86_64-pc-windows-msvc -p solidcraft`.
|
||||
|
||||
On Windows, SolidCraft detects a connected 3Dconnexion SpaceNavigator or SpaceMouse through its
|
||||
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.
|
||||
|
||||
In the browser: `cd apps/solvecraft-web && trunk build --release --public-url ./` (WebGPU, or
|
||||
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
|
||||
WebGL2 with `?webgl`; `?sample` opens the sample part).
|
||||
|
||||
## Scripts
|
||||
@@ -129,12 +183,12 @@ A script is a JSON list of commands, the same ones the UI runs:
|
||||
|
||||
## AI agents (MCP)
|
||||
|
||||
`solvecraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server on
|
||||
`solidcraft-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 `solvecraft --control PORT`, so you can watch the agent model. Tools:
|
||||
started with `solidcraft --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`. Claude Code: `claude mcp add solvecraft -- solvecraft-cli mcp`. Details, client
|
||||
`save`. Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. Details, client
|
||||
configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
## Architecture
|
||||
@@ -148,7 +202,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
|
||||
| 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 | `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 |
|
||||
| apps | `solidcraft`, `solidcraft-cli` | desktop app, headless CLI |
|
||||
<!-- ANCHOR_END: architecture -->
|
||||
|
||||
## Contributing
|
||||
@@ -166,6 +220,7 @@ 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.
|
||||
|
||||
References to other products describe workflow comparisons and benchmark provenance.
|
||||
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.
|
||||
@@ -7,9 +7,9 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
|
||||
| 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, oracle harness. |
|
||||
| Command parity (in scope) | **335 / 541 (62%)** in-scope commands; SOLID + SKETCH **188 / 291 (65%)** — [docs/parity.md](docs/parity.md); registry breadth does not establish workflow or geometric parity |
|
||||
| Fusion oracle | **76 / 76 parts match** in the checked-in historical report — [docs/oracle.md](docs/oracle.md). The current sprint adds independent analytic and UI regressions; the local Fusion oracle corpus has not been rerun. |
|
||||
| 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 | **76 / 76 parts match** — [docs/oracle.md](docs/oracle.md): public cases in `tests/oracle/` with analytic (55) or FreeCAD-computed (21) expected values, run by CI on every PR. |
|
||||
| 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,8 +20,7 @@ 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. Inspection in
|
||||
Fusion remains an external acceptance step. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
acceptance scenarios now include board resizes; STEP reopening is automated. See [WIP reconciliation](docs/wip-reconciliation.md).
|
||||
|
||||
In scope, in priority order:
|
||||
1. SOLID and SKETCH (now).
|
||||
@@ -38,35 +37,59 @@ In scope, in priority order:
|
||||
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, 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.
|
||||
PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
|
||||
## 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. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 |
|
||||
| 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 |
|
||||
| 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: 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 |
|
||||
| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), 3MF export and 3MF/STL import as mesh bodies (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, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names), 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 | 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 | 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, 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. 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 (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 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) | in progress | 36 |
|
||||
| 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 | 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": "solvecraft/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/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 |
|
||||
| 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 |
|
||||
|
||||
## Dimensioning and error reporting
|
||||
|
||||
- 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 selected outline's context menu or the Edit Text command.
|
||||
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.
|
||||
- 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
|
||||
@@ -75,6 +98,14 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
- 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).
|
||||
|
||||
## 0.2.0 release batch
|
||||
|
||||
@@ -116,6 +147,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## View navigation
|
||||
|
||||
- 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
|
||||
@@ -141,7 +174,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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.
|
||||
edges, where straight or curved cap edges are trimmed to the contacts (the straight edge
|
||||
between two overlapping extruded arcs).
|
||||
- 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;
|
||||
@@ -155,16 +189,15 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
containment shortcut. 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; all 29 Fusion STEP files pass
|
||||
`cargo xtask step-corpus`) reads solids (B-rep with analytic and B-spline geometry), units,
|
||||
- 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,
|
||||
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 (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.
|
||||
- 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
|
||||
@@ -231,6 +264,10 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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.
|
||||
@@ -250,6 +287,8 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
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.
|
||||
@@ -274,23 +313,57 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
- 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.
|
||||
Circular cuts crossing bends remain unsupported; free-form bases use bounded sampling.
|
||||
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.
|
||||
- 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, not a new Fusion oracle result. Under-resolved fits return an error.
|
||||
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.
|
||||
|
||||
## 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;
|
||||
evaluated geometry caching and instant cold display remain separate follow-up work.
|
||||
See [container format](docs/design-container.md) and [performance measurements](docs/perf.md).
|
||||
- Desktop Open can show an experimental, opt-in bounded saved display preview while rebuilding in the existing
|
||||
file worker. Ordinary saves omit display caches; explicit `preview: true` saves cache
|
||||
only already warm meshes. Document hashes and application versions reject
|
||||
stale previews. Preview geometry cannot be edited or measured, and cancelled or stale
|
||||
completions cannot replace a newer document. Evaluated B-rep restoration remains open.
|
||||
|
||||
- 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
|
||||
@@ -304,6 +377,15 @@ headline counts in-scope tabs only and lists the deferred ones separately.
|
||||
|
||||
## 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.
|
||||
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Third-party software in SolidCraft
|
||||
|
||||
SolidCraft (the desktop app, solvecraft-cli and the web app) is built from the 454 crates below.
|
||||
SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 454 crates below.
|
||||
Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files.
|
||||
SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE.
|
||||
|
||||
@@ -13345,3 +13345,113 @@ truck-geotrait 0.4.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-modeling 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-polymesh 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
truck-topology 0.6.0: Apache-2.0 (https://github.com/ricosjp/truck)
|
||||
|
||||
================================================================================
|
||||
Bundled fonts (SIL Open Font License, Version 1.1)
|
||||
================================================================================
|
||||
|
||||
- LiberationSans-Regular.ttf (crates/sketch/fonts/LiberationSans-Regular.ttf)
|
||||
Copyright 2012 Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google)
|
||||
Source: https://github.com/liberationfonts
|
||||
|
||||
- BIZUDPGothic-Regular.ttf (apps/solidcraft-web/fonts/BIZUDPGothic-Regular.ttf)
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
Source: https://github.com/googlefonts/morisawa-biz-ud-gothic
|
||||
|
||||
================================================================================
|
||||
SIL OPEN FONT LICENSE Version 1.1
|
||||
================================================================================
|
||||
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,25 @@
|
||||
[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 }
|
||||
@@ -0,0 +1,30 @@
|
||||
//! `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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! `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(())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `solvecraft-cli fuzz`: random modelling sessions and boolean checks (docs/robustness.md).
|
||||
//! `solidcraft-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 solvecraft_engine::fuzz;
|
||||
use solvecraft_engine::fuzz::boolean_case;
|
||||
use solidcraft_engine::fuzz;
|
||||
use solidcraft_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,6 +33,8 @@ 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() {
|
||||
@@ -43,7 +45,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) = solvecraft_engine::fuzz::boolean_shapes(seed);
|
||||
let (a, b) = solidcraft_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);
|
||||
@@ -179,9 +181,23 @@ 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() {
|
||||
@@ -220,5 +236,28 @@ 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}");
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
//! SolveCraft headless command line.
|
||||
//! SolidCraft 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 bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! 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)
|
||||
//! solidcraft-cli run <script.json|design.solidcraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] [--allow-errors]
|
||||
//! solidcraft-cli eval <script.json|design.solidcraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! 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] 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 crashes;
|
||||
mod fuzz;
|
||||
mod oracle;
|
||||
mod recipe;
|
||||
mod seams;
|
||||
mod spatial;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -28,24 +28,25 @@ use serde_json::{Value, json};
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_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 bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
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
|
||||
solidcraft-cli run <script.json|design.solidcraft|part.step> [--in design.solidcraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] [--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> (alias: inspect; or --in FILE)
|
||||
solidcraft-cli bench-open <design.solidcraft | part.step> [--parse-only]
|
||||
solidcraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
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]
|
||||
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 {
|
||||
@@ -73,15 +74,15 @@ fn main() -> ExitCode {
|
||||
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("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(solvecraft_engine::licences::all().as_bytes());
|
||||
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.
|
||||
@@ -90,7 +91,7 @@ fn main() -> ExitCode {
|
||||
Some("bench-edit") => cmd_bench_edit(&args[1..]),
|
||||
Some("bench-open") => cmd_bench_open(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("solidcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.to_string()),
|
||||
@@ -111,19 +112,26 @@ fn pretty(v: &Value) -> String {
|
||||
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 = solvecraft_engine::io::vfs::read_to_string(path, solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| format!("{path}: {e}"))?;
|
||||
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 solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path) {
|
||||
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 v = read_json(path)?;
|
||||
if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) {
|
||||
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)?;
|
||||
@@ -147,7 +155,7 @@ 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 };
|
||||
i += if matches!(a.as_str(), "--quiet" | "--results" | "--allow-errors") { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
return Some(a);
|
||||
@@ -182,6 +190,12 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
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())?;
|
||||
}
|
||||
@@ -197,6 +211,19 @@ fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
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 path = flag(args, "--in").or_else(|| positional(args).map(String::as_str)).ok_or_else(|| USAGE.to_string())?;
|
||||
let mut s = load(path)?;
|
||||
@@ -220,11 +247,11 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> {
|
||||
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);
|
||||
let mut cam = solidcraft_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 scene = solidcraft_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()})));
|
||||
@@ -249,7 +276,7 @@ fn cmd_icon(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn icon_png(size: usize) -> Result<Vec<u8>, String> {
|
||||
solvecraft_engine::render::png(size, size, app_icon::rgba(size)?).ok_or_else(|| "icon: PNG encoding failed".to_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).
|
||||
@@ -287,25 +314,31 @@ fn cmd_exec(args: &[String]) -> Result<(), String> {
|
||||
/// 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")) {
|
||||
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) => 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()),
|
||||
(Some(addr), None) => Box::new(solidcraft_mcp::Remote::connect(addr).map_err(|e| {
|
||||
let port = addr.rsplit(':').next().filter(|p| p.parse::<u16>().is_ok()).unwrap_or("PORT");
|
||||
format!(
|
||||
"cannot connect to {addr}: {e}\nno SolidCraft app is listening there; start it with \
|
||||
`solidcraft --control {port}` (or SOLIDCRAFT_CONTROL_PORT={port}) and retry"
|
||||
)
|
||||
})?),
|
||||
(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();
|
||||
solvecraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
solidcraft_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());
|
||||
return Err("usage: solidcraft-cli save-stress <path> <rounds>".into());
|
||||
};
|
||||
let mut s = Session::default();
|
||||
let filler = (0..3000).map(|i| solvecraft_engine::doc::Param {
|
||||
let filler = (0..3000).map(|i| solidcraft_engine::doc::Param {
|
||||
name: format!("p{i}"),
|
||||
expr: format!("{i} mm + 0.5 mm"),
|
||||
unit: "mm".into(),
|
||||
@@ -324,7 +357,7 @@ fn cmd_save_stress(args: &[String]) -> Result<(), String> {
|
||||
/// 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());
|
||||
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 };
|
||||
@@ -352,21 +385,21 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> {
|
||||
|
||||
/// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing.
|
||||
fn cmd_bench_open(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]")?;
|
||||
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 = solvecraft_engine::io::is_step_path(path);
|
||||
let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES };
|
||||
let bytes = solvecraft_engine::io::vfs::read(path, limit).map_err(|e| format!("{path}: {e}"))?;
|
||||
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();
|
||||
let doc = if step {
|
||||
let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?;
|
||||
let mut doc = solvecraft_engine::doc::Document::new(&imported.name);
|
||||
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())?;
|
||||
doc
|
||||
} else {
|
||||
solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?
|
||||
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()}));
|
||||
@@ -0,0 +1,263 @@
|
||||
//! 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(()) }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts.
|
||||
//! Reference-test recipes (`tests/oracle/*/recipe.json`) → SolidCraft 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,14 +85,6 @@ 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);
|
||||
@@ -545,8 +537,8 @@ fn edge_points(f: &Value) -> Value {
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the
|
||||
/// features are the recipe's own names.
|
||||
/// A pattern's source features may carry 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)
|
||||
@@ -0,0 +1,333 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn registry_output_is_complete_json() {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").output().unwrap();
|
||||
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();
|
||||
@@ -16,7 +16,7 @@ fn registry_output_is_complete_json() {
|
||||
|
||||
#[test]
|
||||
fn closed_registry_pipe_exits_without_panicking() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
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());
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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`) |
|
||||
| `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) |
|
||||
|
||||
The scripts that made them are in `scripts/` (`issue-489-stacked-join` was drawn in the desktop
|
||||
app and has none). 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,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
{
|
||||
"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,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"format": "solvecraft/1",
|
||||
"format": "solidcraft/1",
|
||||
"name": "Untitled",
|
||||
"units": "mm",
|
||||
"params": [],
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! 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); 9] = [
|
||||
("issue-489-stacked-join", "Body1", Some(&[([1., 0., 0.], 35.), ([-1., 0., 0.], 35.)]), 491),
|
||||
("issue-489-stacked-join", "Body1", Some(&[([0., 0., 1.], 30.)]), 492),
|
||||
("m0-revolve-primitives", "Body2", Some(&[([-FRAC_1_SQRT_2, 0., FRAC_1_SQRT_2], -21. * FRAC_1_SQRT_2)]), 493),
|
||||
("split-body-pieces", "Body1", Some(&[([0., FRAC_1_SQRT_2, FRAC_1_SQRT_2], 38.5 * FRAC_1_SQRT_2), ([0., -1., 0.], -18.)]), 493),
|
||||
("split-body-pieces", "Body1 (1)", Some(&[([0., 1., 0.], 12.)]), 493),
|
||||
("m0-revolve-primitives", "Body1", None, 494),
|
||||
("naming-split-pieces", "Body1", None, 494),
|
||||
("modern-plastic", "Body1", None, 494),
|
||||
("modern-sheet-metal", "Body1", None, 495),
|
||||
];
|
||||
use std::f64::consts::FRAC_1_SQRT_2;
|
||||
|
||||
/// 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 #491 is fixed"]
|
||||
fn a_side_face_join_merged_from_two_extrudes_takes_an_attached_sketch() {
|
||||
known_faces_attach(491);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #492 is fixed"]
|
||||
fn a_face_split_into_pieces_by_a_later_feature_takes_an_attached_sketch() {
|
||||
known_faces_attach(492);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #493 is fixed"]
|
||||
fn cut_walls_and_chamfer_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(493);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #494 is fixed"]
|
||||
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
|
||||
known_faces_attach(494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #495 is fixed"]
|
||||
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(495);
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::process::Command;
|
||||
#[test]
|
||||
fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "2"]).output().unwrap();
|
||||
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}");
|
||||
@@ -15,14 +15,14 @@ fn overflowing_seed_range_is_an_error_not_a_panic() {
|
||||
#[test]
|
||||
fn empty_seed_range_at_maximum_is_valid() {
|
||||
let output =
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli")).args(["fuzz", "--from", "18446744073709551615", "--count", "0"]).output().unwrap();
|
||||
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_solvecraft-cli"))
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["fuzz", "--from", "18446744073709551615", "--count", "1", "--steps", "0", "--timeout", "0"])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -6,17 +6,17 @@ use std::time::Duration;
|
||||
|
||||
fn round_of(path: &std::path::Path) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let doc = solvecraft_engine::io::read_design(&bytes).ok()?;
|
||||
let doc = solidcraft_engine::io::read_design(&bytes).ok()?;
|
||||
doc.params.iter().find(|p| p.name == "save_round").map(|p| p.expr.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killed_mid_save_the_old_file_survives() {
|
||||
let exe = env!("CARGO_BIN_EXE_solvecraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-kill-mid-save-{}", std::process::id()));
|
||||
let exe = env!("CARGO_BIN_EXE_solidcraft-cli");
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-kill-mid-save-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("design.solvecraft");
|
||||
let path = dir.join("design.solidcraft");
|
||||
let ok = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "1"]).status().unwrap();
|
||||
assert!(ok.success());
|
||||
assert_eq!(round_of(&path).as_deref(), Some("0"));
|
||||
@@ -44,7 +44,7 @@ fn killed_mid_save_the_old_file_survives() {
|
||||
let round = round_of(&path);
|
||||
assert!(round.is_some(), "after kill {i} the design file is torn or missing");
|
||||
seen.insert(round);
|
||||
let bak = solvecraft_engine::io::backup_path(&path);
|
||||
let bak = solidcraft_engine::io::backup_path(&path);
|
||||
if bak.exists() {
|
||||
assert!(round_of(&bak).is_some(), "after kill {i} the backup is torn");
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! `mcp --connect` to a port where no app listens tells the user how to start one (#270).
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_a_listening_app_names_the_control_flag() {
|
||||
// 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 output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
.args(["mcp", "--connect", &format!("127.0.0.1:{port}")])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(!output.status.success(), "{stderr}");
|
||||
assert!(stderr.contains("--control"), "say how to start the app with a control port: {stderr}");
|
||||
}
|
||||
@@ -12,7 +12,7 @@ struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-run-results-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
||||
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)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ impl Drop for Scratch {
|
||||
}
|
||||
|
||||
fn cli() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_solvecraft-cli"))
|
||||
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
||||
}
|
||||
|
||||
fn json_output(output: Output) -> Value {
|
||||
@@ -43,10 +43,27 @@ 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();
|
||||
let saved = dir.0.join("box.solvecraft");
|
||||
// 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(
|
||||
@@ -64,8 +81,8 @@ fn results_still_save_and_export_the_script_once() {
|
||||
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.solvecraft");
|
||||
let saved = dir.0.join("combined.solvecraft");
|
||||
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);
|
||||
@@ -77,7 +94,7 @@ fn results_with_base_design_and_quiet_still_report_and_save() {
|
||||
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.solvecraft" } else { "box.stl" });
|
||||
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());
|
||||
@@ -89,9 +106,29 @@ fn results_propagate_output_errors() {
|
||||
#[test]
|
||||
fn results_without_script_still_fail() {
|
||||
let dir = Scratch::new();
|
||||
let base = dir.0.join("base.solvecraft");
|
||||
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/"));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! 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,5 +1,5 @@
|
||||
[package]
|
||||
name = "solvecraft-web"
|
||||
name = "solidcraft-web"
|
||||
description = "SolidCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
@@ -10,12 +10,12 @@ publish = false
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "solvecraft-web"
|
||||
name = "solidcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
solidcraft-engine = { workspace = true }
|
||||
solidcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -29,5 +29,5 @@ web-sys = { version = "0.3", features = [
|
||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "Response",
|
||||
] }
|
||||
@@ -1,4 +1,4 @@
|
||||
# Build with: trunk build --release (from apps/solvecraft-web)
|
||||
# Build with: trunk build --release (from apps/solidcraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The BIZ UDGothic Project Authors (https://github.com/googlefonts/morisawa-biz-ud-mincho)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,36 @@
|
||||
<!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="fonts/BIZUDPGothic-Regular.ttf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="fonts/OFL.txt" data-target-path="fonts" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
#solidcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#solidcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solidcraft_canvas"></canvas>
|
||||
<div id="solidcraft_loading">Loading SolidCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
|
||||
//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
|
||||
//! the engine's in-memory files (`solidcraft_engine::io::vfs`) at start, written back whenever a
|
||||
//! command writes one. Files come in from the computer through a file picker (the File System
|
||||
//! Access API where the browser has it, an `<input type=file>` otherwise) or a drop on the page,
|
||||
//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
|
||||
@@ -9,7 +9,7 @@ use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::{Array, Function, Object, Reflect, Uint8Array};
|
||||
use solvecraft_engine::io::vfs;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::{JsFuture, spawn_local};
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
|
||||
@@ -20,8 +20,11 @@ pub const OPFS: &str = "/opfs/";
|
||||
pub const DOWNLOADS: &str = "/downloads/";
|
||||
/// Files brought in from the computer.
|
||||
pub const UPLOAD: &str = "/upload/";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solvecraft";
|
||||
/// The autosave of unsaved work (in OPFS: `recovery.solidcraft` and `recovery.json`).
|
||||
const RECOVERY: &str = "recovery.solidcraft";
|
||||
/// The autosave earlier releases kept (compatibility shim, docs/compatibility.md): recovered
|
||||
/// when there is no current one.
|
||||
const LEGACY_RECOVERY: &str = "recovery.solvecraft";
|
||||
const RECOVERY_META: &str = "recovery.json";
|
||||
|
||||
/// What to do with a file brought in from the computer.
|
||||
@@ -87,7 +90,10 @@ thread_local! {
|
||||
}
|
||||
|
||||
/// Local storage keys of stored files.
|
||||
const LOCAL_PREFIX: &str = "solvecraft.file.";
|
||||
const LOCAL_PREFIX: &str = "solidcraft.file.";
|
||||
/// Local-storage files kept by earlier releases (compatibility shim, docs/compatibility.md):
|
||||
/// still listed and opened; removing one removes either key.
|
||||
const LEGACY_LOCAL_PREFIX: &str = "solvecraft.file.";
|
||||
|
||||
fn local() -> Result<web_sys::Storage, String> {
|
||||
web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
|
||||
@@ -177,7 +183,9 @@ async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
|
||||
async fn remove_file(name: &str) -> Result<(), String> {
|
||||
if local_only() {
|
||||
return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
|
||||
let st = local()?;
|
||||
st.remove_item(&format!("{LEGACY_LOCAL_PREFIX}{name}")).map_err(err)?;
|
||||
return st.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
|
||||
}
|
||||
let dir = root().await?;
|
||||
JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
|
||||
@@ -190,7 +198,7 @@ fn read_local() -> Result<Vec<(String, Vec<u8>, f64)>, String> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..st.length().unwrap_or(0).min(10_000) {
|
||||
let Some(k) = st.key(i).ok().flatten() else { continue };
|
||||
let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
|
||||
let Some(name) = k.strip_prefix(LOCAL_PREFIX).or_else(|| k.strip_prefix(LEGACY_LOCAL_PREFIX)) else { continue };
|
||||
if let Some(v) = st.get_item(&k).ok().flatten() {
|
||||
out.push((name.to_string(), decode(&v), 0.0));
|
||||
}
|
||||
@@ -251,7 +259,14 @@ pub fn start(files: &Rc<Files>) {
|
||||
files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let has_recovery = all.iter().any(|(n, _, _)| n == RECOVERY);
|
||||
for (name, bytes, modified) in all {
|
||||
if name == LEGACY_RECOVERY {
|
||||
if !has_recovery {
|
||||
vfs::insert(&format!("{OPFS}{RECOVERY}"), bytes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if name == RECOVERY_META {
|
||||
let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
|
||||
@@ -349,7 +364,7 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
if files.entries.borrow().iter().any(|e| e.name == to) {
|
||||
return Err(format!("there is already a file named {to}"));
|
||||
}
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}"), solvecraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
let bytes = vfs::read(&format!("{OPFS}{from}"), solidcraft_engine::io::MAX_DESIGN_BYTES).map_err(|e| e.to_string())?;
|
||||
vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
|
||||
delete(files, from);
|
||||
flush(files);
|
||||
@@ -357,11 +372,17 @@ pub fn rename(files: &Rc<Files>, from: &str, to: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Keep (or drop) the autosave of the open design.
|
||||
pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
|
||||
pub fn autosave(files: &Rc<Files>, doc: Option<(&solidcraft_engine::doc::Document, Option<&str>)>) {
|
||||
let files = files.clone();
|
||||
match doc {
|
||||
Some((d, path)) => {
|
||||
let bytes = solvecraft_engine::io::write_design(d);
|
||||
let bytes = match solidcraft_engine::io::write_design_container(d) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
files.say(format!("autosave: {e}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
|
||||
spawn_local(async move {
|
||||
let r = async {
|
||||
@@ -375,6 +396,7 @@ pub fn autosave(files: &Rc<Files>, doc: Option<(&solvecraft_engine::doc::Documen
|
||||
}
|
||||
None => spawn_local(async move {
|
||||
let _ = remove_file(RECOVERY).await;
|
||||
let _ = remove_file(LEGACY_RECOVERY).await;
|
||||
let _ = remove_file(RECOVERY_META).await;
|
||||
}),
|
||||
}
|
||||
@@ -436,7 +458,7 @@ fn input_picker(files: &Rc<Files>, intent: Intent) {
|
||||
let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::<web_sys::HtmlInputElement>()) else { return };
|
||||
input.set_type("file");
|
||||
input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
|
||||
input.set_accept(".solidcraft,.solvecraft,.json,.step,.stp,.3mf,.stl");
|
||||
// Attached (hidden) while it is open: some browsers ignore a click on a detached input.
|
||||
let _ = input.set_attribute("style", "display:none");
|
||||
if let Some(body) = doc.body() {
|
||||
@@ -1,6 +1,6 @@
|
||||
//! SolveCraft in the browser.
|
||||
//! SolidCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`solvecraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! sample part.
|
||||
@@ -13,15 +13,15 @@ mod files;
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_engine::io::vfs;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::files::{self, Files, Intent};
|
||||
|
||||
const CANVAS_ID: &str = "solvecraft_canvas";
|
||||
const LOADING_ID: &str = "solvecraft_loading";
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
@@ -46,6 +46,10 @@ mod web {
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
@@ -58,6 +62,9 @@ mod web {
|
||||
rename_focus: bool,
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -72,7 +79,7 @@ mod web {
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
// A design from the computer is kept in the browser (a copy), so Save works.
|
||||
let path = files::free_name(&name);
|
||||
@@ -126,13 +133,13 @@ mod web {
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
|
||||
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
recent.push(p);
|
||||
}
|
||||
}
|
||||
recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
|
||||
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
@@ -199,7 +206,7 @@ mod web {
|
||||
if ui.small_button("Open").clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
@@ -234,7 +241,7 @@ mod web {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path, solvecraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => {
|
||||
if let Err(e) = files::download(&name, &b) {
|
||||
self.app.set_status(e, true);
|
||||
@@ -244,8 +251,10 @@ mod web {
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let bytes = solvecraft_engine::io::write_design(d);
|
||||
if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
|
||||
let result = solidcraft_engine::io::write_design_container(d)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|bytes| files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes));
|
||||
if let Err(e) = result {
|
||||
self.app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
@@ -304,13 +313,45 @@ mod web {
|
||||
}
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
|
||||
/// session falls back to English (no tofu); the saved language preference is kept.
|
||||
fn tick_font(&mut self, ctx: &egui::Context) {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if installed {
|
||||
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
|
||||
} else {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
@@ -323,17 +364,31 @@ mod web {
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(PREFS_KEY, self.app.prefs());
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept (the browser's local storage).
|
||||
const PREFS_KEY: &str = "solvecraft.prefs";
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
|
||||
Ok(uint8_arr.to_vec())
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -355,6 +410,7 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
@@ -366,13 +422,13 @@ mod web {
|
||||
})),
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
@@ -380,7 +436,7 @@ mod web {
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solvecraft_engine::sample::script()) {
|
||||
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();
|
||||
@@ -398,6 +454,8 @@ mod web {
|
||||
renaming: None,
|
||||
rename_focus: false,
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
@@ -421,5 +479,5 @@ fn main() {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("solvecraft-web only runs in the browser: build it with `trunk build --release` in apps/solvecraft-web");
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
name = "solidcraft"
|
||||
description = "SolidCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "solvecraft"
|
||||
default-run = "solidcraft"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-engine = { workspace = true }
|
||||
solvecraft-ui-egui = { workspace = true }
|
||||
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 }
|
||||
|
||||
[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"] }
|
||||
|
||||
@@ -56,6 +56,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let rc = out.join("solidcraft.rc");
|
||||
std::fs::write(&rc, format!("1 ICON \"{}\"\n", icon.display().to_string().replace('\\', "/")))?;
|
||||
// Missing resource tools are a build error, rather than silently shipping a generic icon.
|
||||
embed_resource::compile_for(&rc, ["solvecraft"], embed_resource::NONE).manifest_required()?;
|
||||
embed_resource::compile_for(&rc, ["solidcraft"], embed_resource::NONE).manifest_required()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,8 +8,8 @@ use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::MAX_LINE;
|
||||
use solvecraft_ui_egui::ControlRequest;
|
||||
use solidcraft_engine::MAX_LINE;
|
||||
use solidcraft_ui_egui::ControlRequest;
|
||||
|
||||
/// Connections served at once. The channel has no authentication beyond being bound to
|
||||
/// loopback, so a flood must not be able to spawn threads without end.
|
||||
@@ -24,11 +24,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("solvecraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("solvecraft: control server listening on 127.0.0.1:{port}");
|
||||
eprintln!("solidcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if OPEN.fetch_add(1, Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||
@@ -0,0 +1,434 @@
|
||||
//! SolidCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solidcraft [--control <port>] [--sample] [--crash-log] [design.solidcraft | part.step]`
|
||||
//!
|
||||
//! 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)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod space_mouse;
|
||||
|
||||
use solidcraft_engine::{Session, io};
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[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) {
|
||||
// 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);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.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()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let dialog = rfd::FileDialog::new().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)
|
||||
};
|
||||
dialog.save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
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();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 app id. 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(APP_ID);
|
||||
if let Err(e) = adopt_settings(¤t, &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, legacy: &std::path::Path, legacy_eframe: Option<&std::path::Path>) -> std::io::Result<()> {
|
||||
let fresh = !current.exists();
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.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 stays the packages' reverse-DNS id,
|
||||
/// which is kept from before the rename (compatibility shim, docs/compatibility.md).
|
||||
const APP_ID: &str = "ai.storyteller.solvecraft";
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
adopt_legacy_settings();
|
||||
let samples = msaa();
|
||||
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,
|
||||
"--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] [design.solidcraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
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.
|
||||
persistence_path: settings_dir().map(|d| d.join("app.ron")),
|
||||
..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();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
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);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples);
|
||||
// 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();
|
||||
}
|
||||
for f in &files {
|
||||
app.open_path_async(f);
|
||||
}
|
||||
// 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)));
|
||||
}
|
||||
// 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);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(¤t, &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(¤t, &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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
//! 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 }
|
||||
}
|
||||
}
|
||||
|
||||
#[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_default(), 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_default();
|
||||
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 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
description = "SolidCraft 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,277 +0,0 @@
|
||||
//! 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; SolidCraft 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 SolidCraft/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; SolidCraft'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,321 +0,0 @@
|
||||
//! 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 ° {
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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,251 +0,0 @@
|
||||
//! Designs saved by older SolveCraft 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 solvecraft_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 == "solvecraft")).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, solvecraft_engine::doc::format::format_string(solvecraft_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 = solvecraft_engine::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(again, *s.doc, "{name}: round trip");
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// 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) = solvecraft_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": "solvecraft/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"));
|
||||
}
|
||||
@@ -1,19 +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>
|
||||
<link data-trunk rel="rust" data-bin="solvecraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #e8ebf0; }
|
||||
#solvecraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#solvecraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #4a5060; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="solvecraft_canvas"></canvas>
|
||||
<div id="solvecraft_loading">Loading SolidCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,263 +0,0 @@
|
||||
//! SolveCraft desktop app.
|
||||
//!
|
||||
//! Usage: `solvecraft [--control <port>] [--sample] [design.solvecraft | part.step]`
|
||||
//!
|
||||
//! 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 `SOLVECRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`.
|
||||
//! See `solvecraft_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)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[path = "../../app_icon.rs"]
|
||||
mod app_icon;
|
||||
mod control_server;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod space_mouse;
|
||||
|
||||
use solvecraft_engine::Session;
|
||||
use solvecraft_ui_egui::{Services, SolveApp};
|
||||
|
||||
struct App {
|
||||
solve: SolveApp,
|
||||
#[cfg(target_os = "windows")]
|
||||
space_mouse: space_mouse::SpaceMouse,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// 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);
|
||||
solvecraft_ui_egui::documents::request_quit(&mut self.solve);
|
||||
}
|
||||
self.solve.logic(ctx);
|
||||
#[cfg(target_os = "windows")]
|
||||
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(PREFS_KEY, self.solve.prefs());
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
solvecraft_ui_egui::recovery_ui::close(&mut self.solve);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the preferences (theme) are kept in eframe's storage.
|
||||
const PREFS_KEY: &str = "solvecraft.prefs";
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(
|
||||
"Designs, STEP, IGES and meshes",
|
||||
&["solvecraft", "step", "stp", "STEP", "STP", "igs", "iges", "IGS", "IGES", "3mf", "3MF", "stl", "STL"],
|
||||
)
|
||||
.add_filter("SolidCraft design", &["solvecraft"])
|
||||
.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()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new().set_file_name(name).add_filter("File", exts).save_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
|
||||
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();
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// The anti-aliasing preference, kept next to eframe's storage (it is needed before the
|
||||
/// window opens).
|
||||
fn graphics_path() -> Option<std::path::PathBuf> {
|
||||
eframe::storage_dir("SolveCraft").map(|d| d.join("graphics.json"))
|
||||
}
|
||||
|
||||
/// Anti-aliasing samples from Preferences (4 when unset).
|
||||
fn msaa() -> u16 {
|
||||
graphics_path()
|
||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.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
|
||||
/// (`solvecraft_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> {
|
||||
Ok(egui::ViewportBuilder::default()
|
||||
.with_title("SolidCraft")
|
||||
.with_icon(egui::IconData {
|
||||
rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?,
|
||||
width: 64,
|
||||
height: 64,
|
||||
})
|
||||
.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("ai.storyteller.solvecraft"))
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let samples = msaa();
|
||||
let mut control_port: Option<u16> = std::env::var("SOLVECRAFT_CONTROL_PORT").ok().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,
|
||||
"--version" | "-V" => {
|
||||
println!("solvecraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
println!("usage: solvecraft [--control PORT] [--sample] [design.solvecraft | part.step]");
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: app_viewport()?,
|
||||
depth_buffer: DEPTH_BITS,
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
multisampling: samples,
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; SOLVECRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("SOLVECRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"SolidCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let mut app = SolveApp::new(Session::default(), services());
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
|
||||
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);
|
||||
app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, 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(&solvecraft_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();
|
||||
}
|
||||
for f in &files {
|
||||
app.open_path_async(f);
|
||||
}
|
||||
// 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) = solvecraft_engine::recovery::default_dir() {
|
||||
let every = if app.autosave_minutes > 0.0 { app.autosave_minutes * 60.0 } else { f64::MAX / 4.0 };
|
||||
solvecraft_ui_egui::recovery_ui::start(&mut app, &dir, std::time::Duration::from_secs_f64(every.min(1e9)));
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
let space_mouse = space_mouse::SpaceMouse::start(&cc.egui_ctx);
|
||||
Ok(Box::new(App {
|
||||
solve: app,
|
||||
#[cfg(target_os = "windows")]
|
||||
space_mouse,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
#[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("ai.storyteller.solvecraft"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod msaa_tests {
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
//! Windows 3Dconnexion HID navigation. The device reader never touches the document or UI;
|
||||
//! it publishes the latest axes, and the egui thread applies them once per frame.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use egui::Context;
|
||||
use hidapi::{DeviceInfo, HidApi};
|
||||
use solvecraft_engine::render::Camera;
|
||||
use solvecraft_ui_egui::SolveApp;
|
||||
|
||||
const STALE_AFTER: Duration = Duration::from_millis(150);
|
||||
const RETRY_AFTER: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Axes {
|
||||
translation: [i16; 3],
|
||||
rotation: [i16; 3],
|
||||
}
|
||||
|
||||
#[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.
|
||||
/// The HID usage excludes ordinary Logitech mice and the keyboard interfaces of SpaceMice.
|
||||
fn is_3d_mouse(info: &DeviceInfo) -> bool {
|
||||
matches!(info.vendor_id(), 0x046d | 0x256f) && 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.
|
||||
fn decode_report(report: &[u8], reading: &mut Reading, now: Instant) -> bool {
|
||||
match report.first().copied() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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 mut report = [0_u8; 64];
|
||||
loop {
|
||||
match device.read_timeout(&mut report, 100) {
|
||||
Ok(0) => {}
|
||||
Ok(size) => {
|
||||
if let Ok(mut reading) = shared.lock()
|
||||
&& decode_report(&report[..size], &mut reading, Instant::now())
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::info!("3D mouse disconnected: {error}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(mut reading) = shared.lock() {
|
||||
*reading = Reading::new(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
std::thread::sleep(RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Reading>>,
|
||||
last_frame: 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("solvecraft-3d-mouse".into()).spawn(move || reader(worker_ctx, worker_state)) {
|
||||
log::warn!("3D mouse: reader could not start: {error}");
|
||||
}
|
||||
Self { shared, last_frame: 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 !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, axes, seconds * speed.clamp(0.1, 5.0), height, app.session.active_sketch.is_some());
|
||||
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, axes: Axes, seconds: f64, height: f64, sketch: bool) {
|
||||
if !(seconds > 0.0) || !seconds.is_finite() {
|
||||
return;
|
||||
}
|
||||
let [x, y, z] = axes.translation.map(normalized);
|
||||
let [rx, ry, rz] = axes.rotation.map(normalized);
|
||||
cam.pan(x * height * 0.6 * seconds, -y * height * 0.6 * seconds, height);
|
||||
cam.zoom_at((-z * 1.5 * seconds).exp(), None);
|
||||
if !sketch {
|
||||
cam.orbit(ry * 250.0 * seconds, rx * 250.0 * seconds);
|
||||
cam.roll_by(rz * 2.0 * seconds);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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], &mut reading, now));
|
||||
assert_eq!(reading.current(now), Axes::default());
|
||||
assert!(decode_report(&[1, 0x9c, 0xff, 0x2c, 0x01, 0, 0], &mut reading, now));
|
||||
assert_eq!(reading.current(now).translation, [-100, 300, 0]);
|
||||
assert!(decode_report(&[2, 0, 0, 1, 0, 0xff, 0xff], &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], &mut reading, later));
|
||||
assert!(decode_report(&[1, 1, 0, 2, 0, 3, 0, 0, 0, 0, 0, 0, 0], &mut reading, later));
|
||||
assert_eq!(reading.current(later), 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, Axes { translation: [350, 350, 350], rotation: [350, 350, 350] }, 0.02, 600.0, 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, Axes { translation: [350, 0, 0], rotation: [350, 350, 350] }, 0.02, 600.0, true);
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.roll), orientation);
|
||||
assert!(cam.is_valid());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# SolidCraft macOS icon
|
||||
|
||||
`SolidCraft.icon/` is the approved Icon Composer source, containing separate
|
||||
grid and solid SVG layers and the owner's appearance settings. The saturated
|
||||
blue solid has transparent geometry cutouts instead of painted edge lines.
|
||||
The solid and grid are both scaled to 90%, with separate Default and Dark grid artwork.
|
||||
Icon Composer supplies the system backdrop and Mono material effects.
|
||||
Artwork is original and attributed in `ATTRIBUTION.md`.
|
||||
|
||||
`Assets.car` is compiled by Apple's `actool` and supplies the layered icon on
|
||||
macOS 26 and later. It is checked in so the existing release workflow can still
|
||||
package on older Xcode runners. After editing and saving `SolidCraft.icon`,
|
||||
regenerate the catalog with Xcode 26 or later from the repository root:
|
||||
|
||||
```sh
|
||||
mkdir -p target/macos-icon
|
||||
xcrun actool assets/SolidCraft.icon \
|
||||
--compile target/macos-icon --platform macosx \
|
||||
--minimum-deployment-target 11.0 --app-icon SolidCraft \
|
||||
--output-partial-info-plist target/macos-icon/icon-info.plist \
|
||||
--output-format human-readable-text
|
||||
cp target/macos-icon/Assets.car assets/Assets.car
|
||||
cp target/macos-icon/SolidCraft.icns assets/icon.icns
|
||||
```
|
||||
|
||||
`icon.icns` is the older-macOS fallback generated by the same Apple compiler
|
||||
from the approved composition, retaining its layer sizes and positions.
|
||||
Regenerate both compiled resources together with the commands above.
|
||||
`icon.vectorcraft`, `icon.svg`, `icon.png` and `icon.iconset/` retain the earlier
|
||||
VectorCraft artwork; packaging uses the Icon Composer compilation instead.
|
||||
|
||||
Only `packaging/macos/package.sh` consumes these resources. Its existing signed
|
||||
app/DMG workflow copies the catalog and ICNS into `Contents/Resources`, and
|
||||
`Info.plist` references `SolidCraft` through `CFBundleIconName` and
|
||||
`CFBundleIconFile`. The macOS viewport opts out of eframe's runtime bitmap so
|
||||
Finder, the Dock and the application switcher use the bundle icon. Other
|
||||
platforms retain the existing code-drawn icon and packaging.
|
||||
|
||||
Design references: [Apple app-icon HIG](https://developer.apple.com/design/human-interface-guidelines/app-icons)
|
||||
and [Icon Composer documentation](https://developer.apple.com/documentation/xcode/creating-your-app-icon-using-icon-composer).
|
||||
No Apple artwork is shipped.
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="699.707" gradientTransform="matrix(1 0 0 0.5 512 740.159)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#81ace1"/>
|
||||
<stop offset="0.3" stop-color="#81ace1" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#81ace1" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#81ace1" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<title>SolidCraft macOS application icon</title>
|
||||
<g id="Experiment___faint_origin_grid" data-name="Experiment • faint origin grid">
|
||||
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M-19.692 -364.359 L1043.692 248.564 M-19.692 248.564 L1043.692 -364.359 M-19.692 -250.351 L1043.692 362.572 M-19.692 362.572 L1043.692 -250.351 M-19.692 -136.343 L1043.692 476.58 M-19.692 476.58 L1043.692 -136.343 M-19.692 -22.335 L1043.692 590.588 M-19.692 590.588 L1043.692 -22.335 M-19.692 91.673 L1043.692 704.596 M-19.692 704.596 L1043.692 91.673 M-19.692 205.681 L1043.692 818.604 M-19.692 818.604 L1043.692 205.681 M-19.692 319.689 L1043.692 932.612 M-19.692 932.612 L1043.692 319.689 M-19.692 547.705 L1043.692 1160.628 M-19.692 1160.628 L1043.692 547.705 M-19.692 661.713 L1043.692 1274.636 M-19.692 1274.636 L1043.692 661.713 M-19.692 775.721 L1043.692 1388.644 M-19.692 1388.644 L1043.692 775.721 M-19.692 889.729 L1043.692 1502.652 M-19.692 1502.652 L1043.692 889.729 M-19.692 1003.737 L1043.692 1616.66 M-19.692 1616.66 L1043.692 1003.737 M-19.692 1117.745 L1043.692 1730.668 M-19.692 1730.668 L1043.692 1117.745" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.2" stroke-miterlimit="10" opacity="0.28"/>
|
||||
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M-19.692 433.697 L1043.692 1046.62 M-19.692 1046.62 L1043.692 433.697" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.6" stroke-miterlimit="10" opacity="0.28"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="radial-gradient-1" cx="0" cy="0" r="699.707" gradientTransform="matrix(1 0 0 0.5 512 740.159)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#507eaa"/>
|
||||
<stop offset="0.3" stop-color="#507eaa" stop-opacity="0.9"/>
|
||||
<stop offset="0.65" stop-color="#507eaa" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#507eaa" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<title>Untitled-2</title>
|
||||
<g id="_02_Grid" data-name="02 Grid">
|
||||
<path id="Grid___elliptical_radial_alpha_beneath_solid" data-name="Grid • elliptical radial alpha beneath solid" d="M-19.692 -364.359 L1043.692 248.564 M-19.692 248.564 L1043.692 -364.359 M-19.692 -250.351 L1043.692 362.572 M-19.692 362.572 L1043.692 -250.351 M-19.692 -136.343 L1043.692 476.58 M-19.692 476.58 L1043.692 -136.343 M-19.692 -22.335 L1043.692 590.588 M-19.692 590.588 L1043.692 -22.335 M-19.692 91.673 L1043.692 704.596 M-19.692 704.596 L1043.692 91.673 M-19.692 205.681 L1043.692 818.604 M-19.692 818.604 L1043.692 205.681 M-19.692 319.689 L1043.692 932.612 M-19.692 932.612 L1043.692 319.689 M-19.692 547.705 L1043.692 1160.628 M-19.692 1160.628 L1043.692 547.705 M-19.692 661.713 L1043.692 1274.636 M-19.692 1274.636 L1043.692 661.713 M-19.692 775.721 L1043.692 1388.644 M-19.692 1388.644 L1043.692 775.721 M-19.692 889.729 L1043.692 1502.652 M-19.692 1502.652 L1043.692 889.729 M-19.692 1003.737 L1043.692 1616.66 M-19.692 1616.66 L1043.692 1003.737 M-19.692 1117.745 L1043.692 1730.668 M-19.692 1730.668 L1043.692 1117.745" fill="none" stroke="url(#radial-gradient-1)" stroke-width="3.938" stroke-miterlimit="10" opacity="0.28"/>
|
||||
<path id="Origin_axes___shared_elliptical_radial_alpha" data-name="Origin axes • shared elliptical radial alpha" d="M-19.692 433.697 L1043.692 1046.62 M-19.692 1046.62 L1043.692 433.697" fill="none" stroke="url(#radial-gradient-1)" stroke-width="4.431" stroke-miterlimit="10" opacity="0.28"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient-1" x1="619.14" y1="179.734" x2="694.687" y2="396.761" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1d63c6"/>
|
||||
<stop offset="1" stop-color="#15499e"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="619.14" y1="413.244" x2="790.839" y2="605.547" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#459fff"/>
|
||||
<stop offset="1" stop-color="#2379e7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-3" x1="632.876" y1="399.508" x2="783.971" y2="968.175" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1b66cf"/>
|
||||
<stop offset="1" stop-color="#124596"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-4" x1="207.063" y1="303.357" x2="426.837" y2="968.175" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#258df5"/>
|
||||
<stop offset="1" stop-color="#1461da"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-5" x1="385.63" y1="53.364" x2="522.989" y2="510.769" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#51adff"/>
|
||||
<stop offset="1" stop-color="#2b83f1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<title>SolidCraft • blue cutout 36 pt</title>
|
||||
<g id="Layer_1" data-name="Layer 1">
|
||||
<path id="Inner___upper_step___36_pt_cutout" data-name="Inner • upper step • 36 pt cutout" d="M547.883 282.66 L709.797 188.211 L709.797 375.985 Z" fill="url(#linear-gradient-1)"/>
|
||||
<path id="Top___lower_step___36_pt_cutout" data-name="Top • lower step • 36 pt cutout" d="M727.797 593.626 L727.797 407.136 L889.571 500.381 Z" fill="url(#linear-gradient-2)"/>
|
||||
<path id="Right___milled_recess___36_pt_cutout" data-name="Right • milled recess • 36 pt cutout" d="M530 521.17 L691.797 427.912 L691.797 655.928 L907.594 531.545 L907.594 740.159 L530 957.8 Z" fill="url(#linear-gradient-3)"/>
|
||||
<path id="Left___stepped_face___36_pt_cutout" data-name="Left • stepped face • 36 pt cutout" d="M116.406 510.769 L116.406 303.529 L494 521.17 L494 957.8 L314.203 854.167 L314.203 624.777 Z" fill="url(#linear-gradient-4)"/>
|
||||
<path id="Top___extruded_profile___36_pt_cutout" data-name="Top • extruded profile • 36 pt cutout" d="M134.374 272.334 L512 53.364 L691.828 157.015 L476.117 282.846 L673.752 396.761 L512 489.993 Z" fill="url(#linear-gradient-5)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"fill" : "system-light",
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : true
|
||||
}
|
||||
],
|
||||
"image-name" : "03-solid-blue-cutout.svg",
|
||||
"name" : "03-solid-blue-cutout",
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"name" : "03 Solid",
|
||||
"shadow" : {
|
||||
"kind" : "none",
|
||||
"opacity" : 0
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.3
|
||||
}
|
||||
},
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode-specializations" : [
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "normal"
|
||||
}
|
||||
],
|
||||
"fill" : "none",
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : false
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : true
|
||||
}
|
||||
],
|
||||
"hidden" : false,
|
||||
"image-name-specializations" : [
|
||||
{
|
||||
"value" : "02-grid.svg"
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : "02-grid-dark.svg"
|
||||
}
|
||||
],
|
||||
"name" : "02-grid",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"value" : 1
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : 1
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"name" : "02 Grid",
|
||||
"shadow" : {
|
||||
"kind" : "none",
|
||||
"opacity" : 0
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.3
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |